過去18ヶ月間、2つの異なるクライアントと一緒にバケーションレンタルプラットフォームの構築に携わってきました。おもちゃのようなプロジェクトではなく、数千のリスティング、決済処理、そして午前2時にキャリアの選択を疑わせるような予約ロジックを持つ実際のビジネスです。ここに、Next.jsとSupabaseを使ってAirbnb型プラットフォームを構築することについて学んだことと、このスタックが2025年にショートタームレンタル業界に参入するスタートアップにとって本当に実行可能な理由をまとめました。

バケーションレンタル市場は2027年までに113億9000万ドルに達すると予測されています(Statista)。Airbnbが大きなシェアを占めていますが、ペット同伴の宿泊、高級別荘、田舎の隠れ家、サーフハウスなどのニッチなプラットフォームが繁栄しているのは、一般的なプラットフォームよりも特定の観客により良くサービスを提供しているからです。Airbnbを打ち負かす必要はありません。あなたのニッチ分野でAirbnbよりも優れたサービスを提供する必要があります。

目次

Build a Vacation Rental Platform with Next.js and Supabase

バケーションレンタルプラットフォーム向けNext.js + Supabaseの理由

率直に言いますと、この開発は数十の異なるスタックで行うことができます。Laravel、Rails、Django —すべて良い選択肢です。しかし、Next.js + Supabaseの組み合わせは、レンタルプラットフォーム特に大きなスイートスポットを実現します。

Next.jsは以下を提供します:

  • SEO向けのサーバーサイドレンダリング(リスティングページはランク付けする必要があります)
  • React Server Componentsを備えたApp Routerで高速な初期ロード
  • 別のサーバーなしでバックエンドロジック向けのAPIルート
  • 画像最適化が組み込まれている(プロパティ写真に非常に重要)
  • ほとんど変更されないリスティングページ向けのIncremental Static Regeneration

Supabaseは以下を提供します:

  • 地理的クエリ向けのPostGIS付きPostgreSQL(「20km以内のレンタルを表示」)
  • マルチテナントアプリに実際に機能するRow Level Security(RLS)
  • メッセージングと予約更新向けのリアルタイムサブスクリプション
  • OAuth プロバイダーを備えた組み込み認証
  • CDN配信付きプロパティ画像用ストレージ
  • サーバーレスビジネスロジック向けのEdge Functions

真のキラー機能は、Supabaseが単なるPostgresの上にあるということです。Supabaseの管理提供を超えて成長する必要が出た場合(または必要な場合)、任意のPostgresホストに移行できます。最も重要な資産であるデータに対するベンダーロックインはありません。

フレームワークを評価している場合は、Next.js開発チームがこの正確なスタック上で複数のプラットフォームを出荷しています。

システムアーキテクチャの概要

複数のプロジェクト全体でうまく機能しているハイレベルのアーキテクチャはここにあります:

┌─────────────────────────────────────────────┐
│              Next.js Application             │
│  ┌─────────┐  ┌──────────┐  ┌────────────┐  │
│  │  Pages   │  │   API    │  │  Server    │  │
│  │ (SSR/ISR)│  │  Routes  │  │ Components │  │
│  └────┬─────┘  └────┬─────┘  └─────┬──────┘  │
│       │              │              │         │
│  ┌────▼──────────────▼──────────────▼──────┐  │
│  │         Supabase Client SDK             │  │
│  └────────────────┬────────────────────────┘  │
└───────────────────┼───────────────────────────┘
                    │
         ┌──────────▼──────────┐
         │     Supabase        │
         │  ┌──────────────┐   │
         │  │  PostgreSQL   │   │
         │  │  + PostGIS    │   │
         │  ├──────────────┤   │
         │  │  Auth         │   │
         │  ├──────────────┤   │
         │  │  Storage      │   │
         │  ├──────────────┤   │
         │  │  Realtime     │   │
         │  ├──────────────┤   │
         │  │  Edge Funcs   │   │
         │  └──────────────┘   │
         └─────────────────────┘
                    │
         ┌──────────▼──────────┐
         │  External Services   │
         │  • Stripe Connect    │
         │  • Mapbox/Google Maps│
         │  • SendGrid/Resend   │
         │  • Cloudflare CDN    │
         └─────────────────────┘

主な建築上の決定は、Supabase Edge Functionsを予約作成と支払いウェブフック処理などの業務に不可欠な操作に使用するかたわら、Next.jsのAPIルートを検索クエリとフォーム検証などの軽いタスクに保つことです。この分離は、Stripeウェブフックが発火して予約状態をアトミックに更新する必要がある場合に問題になります。

データベーススキーマ設計

これはほとんどのレンタルプラットフォームが早期に間違える部分であり、後で支払うことになります。本番トラフィックで機能しているスキーマは以下の通りです:

-- PostGISを有効にする
create extension if not exists postgis;

-- プロフィール(Supabase auth.usersを拡張)
create table public.profiles (
  id uuid references auth.users on delete cascade primary key,
  full_name text not null,
  avatar_url text,
  phone text,
  role text check (role in ('guest', 'host', 'admin')) default 'guest',
  stripe_account_id text, -- ホストの支払い用
  identity_verified boolean default false,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- プロパティ/リスティング
create table public.properties (
  id uuid default gen_random_uuid() primary key,
  host_id uuid references public.profiles(id) not null,
  title text not null,
  slug text unique not null,
  description text,
  property_type text check (property_type in (
    'apartment', 'house', 'villa', 'cabin', 'unique'
  )),
  max_guests int not null default 1,
  bedrooms int not null default 0,
  beds int not null default 1,
  bathrooms numeric(3,1) not null default 1,
  amenities text[] default '{}',
  -- 価格設定
  base_price_cents int not null,
  cleaning_fee_cents int default 0,
  currency text default 'USD',
  -- 場所
  address_line1 text,
  city text not null,
  state text,
  country text not null,
  postal_code text,
  location geography(Point, 4326), -- PostGIS
  -- ステータス
  status text check (status in ('draft', 'listed', 'unlisted', 'suspended'))
    default 'draft',
  -- ポリシー
  cancellation_policy text check (cancellation_policy in (
    'flexible', 'moderate', 'strict'
  )) default 'moderate',
  check_in_time time default '15:00',
  check_out_time time default '11:00',
  min_nights int default 1,
  max_nights int default 365,
  -- メタデータ
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- 地理的クエリ向けの空間インデックス
create index properties_location_idx
  on public.properties using gist (location);

-- 可用性と価格オーバーライド
create table public.availability (
  id uuid default gen_random_uuid() primary key,
  property_id uuid references public.properties(id) on delete cascade,
  date date not null,
  available boolean default true,
  price_override_cents int, -- null = ベース価格を使用
  min_nights_override int,
  unique(property_id, date)
);

-- 予約
create table public.bookings (
  id uuid default gen_random_uuid() primary key,
  property_id uuid references public.properties(id) not null,
  guest_id uuid references public.profiles(id) not null,
  check_in date not null,
  check_out date not null,
  guests_count int not null default 1,
  -- 価格スナップショット(作成後は変更不可)
  nightly_rate_cents int not null,
  nights int not null,
  subtotal_cents int not null,
  cleaning_fee_cents int not null,
  service_fee_cents int not null,
  total_cents int not null,
  currency text default 'USD',
  -- ステータス
  status text check (status in (
    'pending', 'confirmed', 'cancelled', 'completed', 'disputed'
  )) default 'pending',
  -- 支払い
  stripe_payment_intent_id text,
  stripe_transfer_id text,
  paid_at timestamptz,
  -- タイムスタンプ
  created_at timestamptz default now(),
  updated_at timestamptz default now(),
  -- DBレベルでの二重予約防止
  exclude using gist (
    property_id with =,
    daterange(check_in, check_out) with &&
  ) where (status in ('pending', 'confirmed'))
);

bookingsテーブルのexclude制約?これは全体スキーマの最も重要な行です。GiSTの除外制約を使用して、DBレベルでの二重予約を防止します。レース条件はありません。「あなたの2秒前に誰かがそれを予約しました」というメールはありません。データベースは文字通り同じプロパティの日付範囲の重複を許可しません。

btree_gist拡張機能が必要です:

create extension if not exists btree_gist;

Build a Vacation Rental Platform with Next.js and Supabase - architecture

予約エンジンの構築

予約フローはすべてのレンタルプラットフォームの中心です。構造方法は以下の通りです:

ステップ1:可用性の確認

// lib/bookings/check-availability.ts
import { createClient } from '@/lib/supabase/server';

export async function checkAvailability(
  propertyId: string,
  checkIn: string,
  checkOut: string
) {
  const supabase = await createClient();

  // ブロック日を確認する
  const { data: blockedDates } = await supabase
    .from('availability')
    .select('date')
    .eq('property_id', propertyId)
    .eq('available', false)
    .gte('date', checkIn)
    .lt('date', checkOut);

  if (blockedDates && blockedDates.length > 0) {
    return { available: false, reason: 'Some dates are unavailable' };
  }

  // 既存の予約を確認する(DBの制約で二重に保険をかける)
  const { data: conflicts } = await supabase
    .from('bookings')
    .select('id')
    .eq('property_id', propertyId)
    .in('status', ['pending', 'confirmed'])
    .or(`check_in.lt.${checkOut},check_out.gt.${checkIn}`);

  if (conflicts && conflicts.length > 0) {
    return { available: false, reason: 'Dates already booked' };
  }

  return { available: true };
}

ステップ2:価格計算

クライアント側の価格計算を信じないでください。常にサーバーで再計算します:

// lib/bookings/calculate-price.ts
export async function calculateBookingPrice(
  propertyId: string,
  checkIn: string,
  checkOut: string
) {
  const supabase = await createClient();

  const { data: property } = await supabase
    .from('properties')
    .select('base_price_cents, cleaning_fee_cents')
    .eq('id', propertyId)
    .single();

  if (!property) throw new Error('Property not found');

  // これらの日付の価格オーバーライドを取得する
  const { data: overrides } = await supabase
    .from('availability')
    .select('date, price_override_cents')
    .eq('property_id', propertyId)
    .gte('date', checkIn)
    .lt('date', checkOut)
    .not('price_override_cents', 'is', null);

  const overrideMap = new Map(
    overrides?.map(o => [o.date, o.price_override_cents]) ?? []
  );

  // 夜ごとの価格を計算する
  let subtotal = 0;
  const start = new Date(checkIn);
  const end = new Date(checkOut);
  const nights = Math.round(
    (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)
  );

  for (let i = 0; i < nights; i++) {
    const current = new Date(start);
    current.setDate(current.getDate() + i);
    const dateStr = current.toISOString().split('T')[0];
    const rate = overrideMap.get(dateStr) ?? property.base_price_cents;
    subtotal += rate;
  }

  const serviceFee = Math.round(subtotal * 0.12); // 12%のサービス手数料
  const total = subtotal + property.cleaning_fee_cents + serviceFee;

  return {
    nights,
    nightlyRate: property.base_price_cents,
    subtotal,
    cleaningFee: property.cleaning_fee_cents,
    serviceFee,
    total,
  };
}

ステップ3:支払いインテント付き予約作成

ここはStripeが参加するところです。Next.js 14+のServer Actionを使用します:

// app/actions/create-booking.ts
'use server';

import Stripe from 'stripe';
import { createClient } from '@/lib/supabase/server';
import { calculateBookingPrice } from '@/lib/bookings/calculate-price';
import { checkAvailability } from '@/lib/bookings/check-availability';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createBooking(formData: FormData) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) throw new Error('Not authenticated');

  const propertyId = formData.get('propertyId') as string;
  const checkIn = formData.get('checkIn') as string;
  const checkOut = formData.get('checkOut') as string;
  const guestsCount = parseInt(formData.get('guests') as string);

  // 可用性を再度検証する
  const availability = await checkAvailability(propertyId, checkIn, checkOut);
  if (!availability.available) {
    return { error: availability.reason };
  }

  // 価格をサーバーサイドで再計算する
  const pricing = await calculateBookingPrice(propertyId, checkIn, checkOut);

  // Stripe Payment Intentを作成する
  const paymentIntent = await stripe.paymentIntents.create({
    amount: pricing.total,
    currency: 'usd',
    metadata: {
      propertyId,
      checkIn,
      checkOut,
      guestId: user.id,
    },
  });

  // 予約を挿入する
  const { data: booking, error } = await supabase
    .from('bookings')
    .insert({
      property_id: propertyId,
      guest_id: user.id,
      check_in: checkIn,
      check_out: checkOut,
      guests_count: guestsCount,
      nightly_rate_cents: pricing.nightlyRate,
      nights: pricing.nights,
      subtotal_cents: pricing.subtotal,
      cleaning_fee_cents: pricing.cleaningFee,
      service_fee_cents: pricing.serviceFee,
      total_cents: pricing.total,
      stripe_payment_intent_id: paymentIntent.id,
      status: 'pending',
    })
    .select()
    .single();

  if (error) {
    // 除外制約は二重予約をキャッチします
    if (error.code === '23P01') {
      return { error: 'These dates were just booked by someone else.' };
    }
    throw error;
  }

  return {
    bookingId: booking.id,
    clientSecret: paymentIntent.client_secret,
  };
}

23P01エラーコードをどのように処理するかに注目してください —これはPostgreSQLの除外違反です。2人のユーザーがまったく同じミリ秒で「予約」をクリックした場合でも、1つの予約だけが通ります。

PostGISでの検索とフィルタリング

地理的検索はレンタルプラットフォームで欠かせません。半径ベースの検索と大量のフィルタリングを処理するPostgres関数をここに示します:

create or replace function search_properties(
  lat double precision,
  lng double precision,
  radius_km int default 50,
  min_price int default 0,
  max_price int default 100000,
  min_bedrooms int default 0,
  guest_count int default 1,
  p_check_in date default null,
  p_check_out date default null
)
returns setof public.properties
language sql stable
as $$
  select p.*
  from public.properties p
  where p.status = 'listed'
    and ST_DWithin(
      p.location,
      ST_MakePoint(lng, lat)::geography,
      radius_km * 1000
    )
    and p.base_price_cents between min_price and max_price
    and p.bedrooms >= min_bedrooms
    and p.max_guests >= guest_count
    and (
      p_check_in is null
      or not exists (
        select 1 from public.bookings b
        where b.property_id = p.id
          and b.status in ('pending', 'confirmed')
          and b.check_in < p_check_out
          and b.check_out > p_check_in
      )
    )
  order by p.location <-> ST_MakePoint(lng, lat)::geography
  limit 50;
$$;

Next.jsから呼び出します:

const { data } = await supabase.rpc('search_properties', {
  lat: 34.0522,
  lng: -118.2437,
  radius_km: 30,
  guest_count: 4,
  p_check_in: '2025-08-01',
  p_check_out: '2025-08-07',
});

これは適切なインデックス作成で100K+のリスティング向けに50ms以下で実行されます。より大きなスケールに達するまでElasticsearchは必要ありません。

認証とマルチロールアクセス

Supabase Authが大部分を処理します。難しい部分は、レンタルプラットフォームの二重ロール性です —誰かがゲストとホストの両方である可能性があります。

プロフィール上のロールフィールドを使用してこれを処理し、最初のリスティングを作成する際にguestからhostにアップグレードします。加えてRow Level Securityポリシー:

-- ホストは自分のプロパティのみ編集できます
create policy "hosts_manage_own_properties" on public.properties
  for all using (host_id = auth.uid());

-- ゲストは一覧表示されたプロパティを表示できます
create policy "anyone_view_listed" on public.properties
  for select using (status = 'listed');

-- ゲストは自分の予約のみ見ることができます
create policy "guests_own_bookings" on public.bookings
  for select using (guest_id = auth.uid());

-- ホストは自分のプロパティの予約を見ることができます
create policy "hosts_property_bookings" on public.bookings
  for select using (
    property_id in (
      select id from public.properties where host_id = auth.uid()
    )
  );

RLSは本当にマルチテナントアプリ向けのSupabaseの最強の機能の1つです。セキュリティルールはデータの隣に存在し、APIミドルウェア全体に散らばっていません。

決済処理と支払い

Stripe Connectを使用してください。絶対に。 これはマーケットプレイスの支払い、スプリット、1099s、KYC、および国際支払いを処理します。代替方法は、あなた自身の送金システムを構築することです。これには...しないでください。

フローはここです:

  1. ホストはStripe Connect Express経由でオンボーディングします(Stripeは身元確認UIを処理します)
  2. ゲストはStripe Payment Intentsを介して支払います
  3. 支払いはチェックインから24時間後まで保持されます
  4. 支払いはプラットフォーム手数料をマイナスしてホストに転送されます

2025年のStripe Connect価格:1回あたり0.25% + $0.25の支払いは標準処理手数料(1回あたり2.9% + $0.30)の上です。$200/夜の予約では、大体Stripe手数料で$6.50を見ています。予算化してください。

リアルタイムメッセージングと通知

Supabase Realtimeはホストゲストメッセージングをストレートフォワードにします:

// 会話のための新しいメッセージをサブスクライブする
const channel = supabase
  .channel(`conversation:${conversationId}`)
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: `conversation_id=eq.${conversationId}`,
    },
    (payload) => {
      setMessages(prev => [...prev, payload.new]);
    }
  )
  .subscribe();

メール通知(予約確認、チェックイン通知)の場合、Supabaseデータベースウェブフック経由でトリガーされたResendまたはSendGridを使用します。Resendの価格は50K メール用の月額$20から始まります—成長するプラットフォームに十分以上です。

画像処理とパフォーマンス

プロパティ写真は変換率を作成または破壊します。各リスティングには15-30の画像がある可能性があり、高速にロードする必要があります。

私のアプローチ:

  • オリジナルをSupabase Storageにアップロードする
  • オンザフライのサイズ変更用のSupabaseの画像変換APIを使用する
  • Next.jsの<Image>コンポーネント経由で正しいsizessrcSetで提供する
  • 折り目の下のすべてを遅延ロードする
  • アップロード時に生成されたちいさなbase64プレビューでblurプレースホルダーを使用する
<Image
  src={`${SUPABASE_URL}/storage/v1/render/image/public/properties/${imageId}?width=800&quality=80`}
  alt={property.title}
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL={image.blur_hash}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

このアプローチは20+写真のあるリスティングページで1秒未満のLCPを配信します。

デプロイとスケーリングの考慮事項

デプロイについて、VercelはNext.js向けの自然な選択です。しかし、ここにはほとんどの記事がスキップする微妙な点があります:レンタルプラットフォーム向けにVercelのEdge Runtimeを慎重に使用してください。 予約フローはStripe SDKと複雑なデータベース操作向けにNode.jsランタイムが必要です。Edgeはミドルウェア(地理リダイレクト、認証チェック)向けに素晴らしいですが、ビジネスロジック向けではありません。

デプロイオプション 最適用途 月額推定コスト
Vercel Pro + Supabase Pro MVP〜10K MAU $45 - $100
Vercel Pro + Supabase Team 10K-100K MAU $200 - $500
Self-hosted Next.js + Supabase Pro コスト最適化 $100 - $300
AWS/GCP + Self-hosted Supabase スケールでの完全な制御 $500+

Supabase Proは月額$25から開始し、8GBデータベース、250GBバンドウィッド、および100GBストレージを含みます。ほとんどのMVPおよび初期段階のプラットフォーム向けに十分です。

コスト内訳:実際に支出すること

以下は、2025年にバケーションレンタルプラットフォームを構築して実行する実際の費用です:

アイテム MVP コスト 月額実行コスト
Supabase Pro - $25
Vercel Pro - $20
Stripe Connect - ~2.9% + $0.30/トランザクション
Mapbox/Google Maps - $0-200(使用量ベース)
Resend(メール) - $20
ドメイン + DNS(Cloudflare) $15/年 $0
開発(エージェンシー) $40K-120K -
開発(ソロ開発者) $15K-40K -
合計月額インフラ - ~$65-265

Sharetribe($299-599/月)やGuestyなどのSaaS プラットフォームで構築した場合と比較すると、カスタム開発の経済性は実際のトラクションを獲得することで意味を持ち始めます。

レンタルプラットフォーム構築に本気で取り組んでいて、このタイプの製品を出荷した経験豊富な開発者が必要な場合は、チームに連絡してくださいか、プロジェクト推定値の価格ページをチェックしてください。 私たちはヘッドレスCMS開発と複雑なNext.jsアプリケーションに特化しています。

FAQ

Airbnbのようなバケーションレンタルプラットフォームを構築するのにどのくらい時間がかかりますか?

リスティング、検索、予約、支払い、およびメッセージング付きの機能的なMVPは、経験のあるチーム2-3人の開発者で3-5ヶ月かかります。ソロ開発者は6-9ヶ月必要かもしれません。これはあなたをローンチに取得します —Airbnbとの機能パリティではなく、15年以上の開発があります。最初にニッチ機能に焦点を合わせてください。

Supabaseは本番のレンタルプラットフォーム向けにスケール可能ですか?

はい、ある程度です。Supabase Proは数万の同時ユーザーを楽に処理します。彼らのTeamプラン(月額$599)は大幅により多くをサポートします。Instagramは数年間、単一のPostgreSQLサーバー上で実行されました。ボトルネックはスケールなので、データベーススケール前に製品市場フィットになります。Supabaseを成長すると、データは標準PostgreSQL内にあります —移行は簡単です。

バケーションレンタルシステムで二重予約をどのように防止しますか?

btree_gist拡張子を持つPostgreSQL除外制約を使用します。これはデータベースレベルで同じプロパティの2つのアクティブな予約が日付範囲を重複できないことを強制します。これが唯一の信頼できる方法です —アプリケーションレベルのチェックはレース条件があります。上記のスキーマ例は、これを実装する方法を正確に示しています。

Stripe Connectを使用するか、自分の支払いシステムを構築する必要がありますか?

Stripe Connect。いつも。自分の支払いスプリットシステムを構築することは、マネー送信ライセンス、KYC/AMLコンプライアンス、国際税務申告、および詐欺防止を伴います。Stripeはこのすべてを処理します。手数料(大体トランザクションあたり3.2%)は価値があります。重要なボリュームを処理していたら、いつでも料金を交渉できます。

プロパティ検索マップで最良の方法は何ですか?

Mapbox GL JSまたはGoogle Maps JavaScriptAPIのフロントエンド向けのSupabaseバックエンド向けのPostGIS。PostGISの空間クエリは適切なGiSTインデックス付きでミリ秒で半径と境界ボックス検索を処理します。Mapbox価格は寛大な無料層で開始します(1ヶ月あたり50K地図ロード)。Google Mapsは月額$200のクレジット後、1000動的地図ロード当たり$7を請求します。

季節の価格設定と動的料金をどのように処理しますか?

ベースプロパティ価格と一緒に日付ベースの可用性/価格オーバーライドテーブルを使用します。予約の夜ごと、その特定の日付の価格オーバーライドがあるかを確認します。ない場合は、ベース価格にフォールバックします。これは季節の料金、週末プレミアム、休日の価格設定、そして最後の瞬間の割引を処理します。いくつかのプラットフォームはまた自動動的価格設定のためのPriceLabs(リスティング月額$19.99)またはBeyond Pricingと統合します。

Next.jsはレンタルプラットフォーム向けにAstroより良いですか?

インタラクティブな予約フロー、メッセージング、ダッシュボードを持つ完全なレンタルプラットフォーム向けに—Next.jsが勝ちます。アプリは大量なクライアント側のインタラクティビティが必要です。Astroは最小限のインタラクティビティ(Astro開発機能をチェック)のあるコンテンツ豊富なサイトで優れています。つまり、予約なしで一覧表示のみのサイトを構築している場合(ディレクトリのような),Astroのパフォーマンスは並外れています。

モバイルアプリについて —React Nativeも必要ですか?

MVP向けではありません。まずNext.js アプリを応答性の高いPWA(Progressive Web App)として構築します。プッシュ通知、オフラインキャッシング、「Add to Home Screen」プロンプトを追加します。これはモバイル使用ケースの80%をカバーします。製品を検証して実際の収益を得たら、ネイティブアプリに投資してください。多くの成功したニッチレンタルプラットフォーム(Hipcamp、Glamping Hub)はウェブファーストをローンチして、後でネイティブアプリを追加しました。