Next.jsとSupabaseで民泊プラットフォームを構築する
あなたのビジターがプロパティ#47をスクロール中、アベイラビリティカレンダーが3秒間停止します。彼らはタブを閉じます。あなたが見ることのない$180の手数料 — それはタイムゾーン変換中にブッキングテーブルがロックされたために起こりました。私はこの光景をNext.jsとSupabaseで構築された2つのライブな民泊プラットフォーム全体で見てきました。両方ともStripe Connectを通じて実際の支払いを処理しています。1つは8ヶ月で3,000件のリスティングに達しました。もう1つはファウンダーが行レベルセキュリティをスキップしてゲスト同士のブッキング履歴を見えるようにしたため、400件でほぼ崩壊しました。違いは才能や予算ではなく — 2週目に下された決定でした。ここが、生き残ったアーキテクチャ、土曜日の午後11時のダブルブッキングを防いだSupabaseトリガー、そしてなぜこのスタックが短期賃貸の複雑さをみんながまだ推奨しているRailsモノリスより良く処理するのかです。
民泊市場は2027年までに$113.9億に達すると予想されています(Statista)。Airbnbは巨大な部分を占めていますが、ニッチなプラットフォーム — ペット対応の滞在、ラグジュアリーヴィラ、田舎の隠れ家、サーフハウス — は一般的なものより特定のオーディエンスにより良くサービスを提供するため繁栄しています。Airbnbに勝つ必要はありません。あなたのニッチで彼らより優れたサービスを提供する必要があります。
目次
- なぜNext.js + Supabaseか民泊プラットフォーム向け
- システムアーキテクチャの概要
- データベーススキーマ設計
- ブッキングエンジンの構築
- PostGISを使用した検索とフィルタリング
- 認証とマルチロールアクセス
- 支払い処理と支払い
- リアルタイムメッセージングと通知
- 画像処理とパフォーマンス
- デプロイと拡張に関する考慮事項
- コスト内訳:実際に費やすもの
- FAQ

なぜNext.js + Supabaseか民泊プラットフォーム向け
はっきり言いましょう:これを数十の異なるスタックで構築できます。Laravel、Rails、Django — すべて素晴らしい選択です。しかしNext.js + Supabaseの組み合わせは、民泊プラットフォーム特に良い場所に当たります。
Next.jsが提供するもの:
- SEO用のサーバーサイドレンダリング(リスティングページはランク付けする必要があります)
- React Server ComponentsのApp Router高速初期ロード用
- 別のサーバーなしのバックエンドロジック用APIルート
- 組み込み画像最適化(不動産写真にとって重要)
- リスティングページ用増分静的再生成(めったに変わらない)
Supabaseが提供するもの:
- 地理的クエリ用PostGIS付きPostgreSQL(「20km以内のレンタルを表示」)
- マルチテナントアプリで実際に機能する行レベルセキュリティ(RLS)
- メッセージングとブッキング更新用リアルタイム購読
- OAuthプロバイダー付き組み込み認証
- CDN配信付き不動産画像用ストレージ
- サーバーレスビジネスロジック用Edge Functions
真の殺し文句機能は、Supabaseがフードの下でただPostgresであることです。Supabaseの管理オファリングから成長しても(または必要に応じて)、任意のPostgresホストに移行できます。最も重要な資産 — あなたのデータについてベンダーロックインはありません。
フレームワークを評価している場合、私たちのNext.js開発チームはこの正確なスタックで複数のプラットフォームをリリースしています。
システムアーキテクチャの概要
複数のプロジェクト全体で機能した高レベルアーキテクチャは次の通りです:
┌─────────────────────────────────────────────┐
│ Next.jsアプリケーション │
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
│ │ ページ │ │ API │ │ サーバー │ │
│ │(SSR/ISR)│ │ ルート │ │ コンポーネント│
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌────▼──────────────▼──────────────▼──────┐ │
│ │ Supabase Client SDK │ │
│ └────────────────┬────────────────────────┘ │
└───────────────────┼───────────────────────────┘
│
┌──────────▼──────────┐
│ Supabase │
│ ┌──────────────┐ │
│ │ PostgreSQL │ │
│ │ + PostGIS │ │
│ ├──────────────┤ │
│ │ 認証 │ │
│ ├──────────────┤ │
│ │ ストレージ │ │
│ ├──────────────┤ │
│ │ リアルタイム │ │
│ ├──────────────┤ │
│ │ Edge Funcs │ │
│ └──────────────┘ │
└─────────────────────┘
│
┌──────────▼──────────┐
│ 外部サービス │
│ • Stripe Connect │
│ • Mapbox/Google Maps│
│ • SendGrid/Resend │
│ • Cloudflare CDN │
└─────────────────────┘
重要なアーキテクチャ決定は、ブッキング作成と支払いウェブフック(webhook)などのビジネスクリティカルな操作には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(),
-- データベースレベルでダブルブッキングを防ぐ
exclude using gist (
property_id with =,
daterange(check_in, check_out) with &&
) where (status in ('pending', 'confirmed'))
);
bookingsテーブルのexclude制約?それは全スキーマの最も重要な行です。GiST排他制約を使用してダブルブッキングをデータベースレベルで防ぎます。レースコンディションはありません。「誠に申し訳ございませんが、あなたの2秒前に誰かがそれを予約しました」というメールはありません。データベースは同じプロパティの重複した日付範囲をリテラルに許可しません。
あなたはbtree_gist拡張機能が必要です:
create extension if not exists btree_gist;

ブッキングエンジンの構築
ブッキングフローはすべての賃貸プラットフォームの心臓です。構造は次の通りです:
ステップ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人のユーザーが同じミリ秒に「Book」をクリックしても、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: '2026-08-01',
p_check_out: '2026-08-07',
});
これは適切なインデックスで100K+リスティングについて50ms以下で実行されます。Elasticsearchはより大きなスケールまで必要ありません。
認証とマルチロールアクセス
Supabase Authが重い作業を処理します。難しい部分は民泊プラットフォームの二重ロール性質です — 誰かがゲストとホストの両方である可能性があります。
プロフィール上のロールフィールドでこれを処理します。彼らが最初のリスティングを作成したときにguestからhostにアップグレードします。加えて行レベルセキュリティポリシー:
-- ホストは自分の不動産だけを編集できる
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、および国際支払いを処理します。代替案は独自の支払い分割システムを構築することです。それは... しないでください。
フロー:
- ホストがStripe Connect Express経由でオンボード(Stripeはアイデンティティ検証UI処理)
- ゲストがStripe Payment Intentsで支払い
- チェックイン+ 24時間まで支払いが保持
- 支払いは支払い手数料を差し引いてホストに転送
2026年Stripe Connect価格:支払いあたり0.25% + $0.25(標準処理手数料上に加えて2.9% + $0.30)。$200/夜のブッキングについて、あなたはおよそ$6.50のStripe手数料を見ている。予算を立ててください。
リアルタイムメッセージングと通知
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();
メール通知(ブッキング確認、チェックイン過去を通知)については、ResendまたはSendGridをデータベースウェブフックから発火させたSupabase Edge Functionsから使用します。Resendの料金は$20/月から50Kメール用です — 成長する民泊プラットフォーム十分以上です。
画像処理とパフォーマンス
不動産写真はコンバージョン率を作ったり壊したりします。各リスティングは15~30画像が必要かもしれ、高速ロード必要です。
私のアプローチ:
- オリジナルをSupabase Storageにアップロード
- オンザフライリサイズするためにSupabaseの画像変換API使用
- 適切な
sizesとsrcSetでNext.js<Image>コンポーネント経由提供 - 折り目下のすべてを遅延ロード
- アップロード時に生成された小さい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と初期段階プラットフォーム十分です。
コスト内訳:実際に費やすもの
ここは本物の民泊プラットフォームが2026年に構築・実行するのにかかるものです:
| 項目 | 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プラットフォームで構築とそれと比較します。カスタム開発の経済学は実際のトラクション起動後、作られます。
民泊プラットフォーム構築について真面目で、このexactタイプの製品をシップした経験のある開発者を望む場合は、私たちのチームに連絡するか価格ページでプロジェクト推定をチェックしてください。私たちはheadless CMS開発と複雑なNext.jsアプリケーション専門です。
FAQ
Airbnbのような民泊プラットフォーム構築にはどのくらい時間がかかりますか?
リスティング、検索、ブッキング、支払い、メッセージ機能的なMVPは、熟練した2~3人開発者チームで3~5ヶ月かかります。ソロ開発者は6~9ヶ月必要があるかもしれません。これは起動を取得 — Airbnbフィーチャ同等ではなく、15+年の開発がそれの裏側です。最初にあなたのニッチ機能に焦点を当ててください。
Supabaseは本番民泊プラットフォーム向けスケール可能十分ですか?
はい、ポイントまで。Supabase Proは数万の同時ユーザーを快適に処理します。彼らのTeamプラン($599/月)はもっと著しくサポート。Instagramは長年単一PostgreSQLサーバーで走りました。あなたのボトルネックはデータベーススケール長い前にプロダクト・マーケット・フィット。あなたがSupabaseを成長させた時、あなたのデータは標準PostgreSQLにあります — 移行は直接です。
民泊システムでダブルブッキングを防ぐ方法は?
PostgreSQL排他制約をbtree_gist拡張機能と使用します。これはデータベースレベルで同じプロパティの2つのアクティブブッキングが重複日付範囲を持つことができないを強制します。それは唯一の信頼できる方法です — アプリケーションレベルチェックはレースコンディションを持ちます。上のスキーマ例はこれをどのように実装するか正確に示します。
Stripe Connectを使用または独自の支払いシステムを構築すべきですか?
Stripe Connect。常に。独自の支払い分割システム構築はマネー転送ライセンス、KYC/AMLコンプライアンス、国際税報告、詐欺防止を含みます。Stripeはすべてこれを処理します。手数料(おおよそトランザクション当たり3.2%)はそれの価値があります。あなたが大きな音量処理してはじめたら、いつでも手数料を交渉できます。
プロパティ検索マップで最良の方法は?
バックエンド向けPostGISとSupabase、フロントエンド向けMapbox GL JSまたはGoogle Maps JavaScript API。PostGIS空間クエリは適切なGiSTインデックスでミリ秒で半径と境界ボックス検索を処理します。Mapboxは無料階層(月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)として構築します。プッシュ通知、オフラインキャッシング、「ホーム画面に追加」プロンプトを加えます。これはモバイル利用ケース80%をカバーします。あなたが製品を検証し、本当の収益があれば、ネイティブアプリに投資します。多くの成功ニッチ賃貸プラットフォーム(Hipcamp、Glamping Hub)ウェブファーストで立ち上げこれラネイティブアプリを後に加えました。