使用 Next.js 和 Supabase 構建度假租賃平台
你的訪客在可用性日曆停滯三秒鐘時滾過物業 #47。他們關閉標籤頁。那是 $180 的傭金,你永遠看不到——這發生是因為你的預訂表在時區轉換期間被鎖定了。我親眼見過這種情況在兩個實時度假租賃平台上上演,都是用 Next.js 和 Supabase 構建的,都通過 Stripe Connect 處理真實付款。一個在八個月內達到 3,000 個列表。另一個在 400 個時幾乎崩潰,因為創始人跳過了行級安全性,讓客人看到彼此的預訂歷史。區別不是天賦或預算——這是在第二週做出的資料庫架構決策。以下是經歷過生存的架構、防止週六晚上 11 點雙重預訂的 Supabase 觸發器,以及為什麼這個堆棧比每個人仍然推薦的 Rails 單體處理短期租賃複雜性更好。
度假租賃市場預計到 2027 年將達到 1,139 億美元(Statista)。Airbnb 擁有巨大的市場份額,但利基平台——寵物友善住宿、豪華別墅、鄉村度假、衝浪屋——蓬勃發展,因為它們比通才更好地服務特定受眾。你不需要擊敗 Airbnb。你需要在你的利基中超越他們。
目錄
- 為什麼選擇 Next.js + Supabase 用於租賃平台
- 系統架構概述
- 資料庫架構設計
- 構建預訂引擎
- 使用 PostGIS 搜索和篩選
- 身份驗證和多角色存取
- 付款處理和結算
- 即時訊息傳遞和通知
- 影像處理和效能
- 部署和擴展考慮
- 成本分解:你實際花費的費用
- 常見問題

為什麼選擇 Next.js + Supabase 用於租賃平台
讓我直言不諱:你可以用數十種不同的堆棧來構建這個。Laravel、Rails、Django——都是不錯的選擇。但 Next.js + Supabase 組合對租賃平台特別來說達到了一個最佳點。
Next.js 為你提供:
- 伺服器端渲染用於 SEO(列表頁面需要排名)
- 使用 React Server Components 的 App Router,用於快速初始載入
- 用於後端邏輯的 API 路由,無需單獨的伺服器
- 內置影像最佳化(對於物業照片至關重要)
- 增量靜態再生成用於很少變化的列表頁面
Supabase 為你提供:
- 帶 PostGIS 的 PostgreSQL,用於地理查詢(「顯示我 20 公里內的租賃」)
- 真正對多租戶應用程式有效的行級安全性 (RLS)
- 用於訊息傳遞和預訂更新的即時訂閱
- 內置身份驗證,帶有 OAuth 提供者
- 帶 CDN 交付的物業影像儲存
- 用於無伺服器業務邏輯的 Edge Functions
真正的殺手功能是 Supabase 本質上是 Postgres。當你超出 Supabase 的託管產品時(或需要超出時),你可以遷移到任何 Postgres 主機。你最關鍵資產——你的數據——沒有供應商鎖定。
如果你在評估框架,我們的 Next.js 開發團隊 已在這個確切的堆棧上交付了多個平台。
系統架構概述
以下是在多個項目中運行良好的高層架構:
┌─────────────────────────────────────────────┐
│ Next.js 應用程式 │
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
│ │ 頁面 │ │ API │ │ 伺服器 │ │
│ │ (SSR/ISR)│ │ 路由 │ │ 元件 │ │
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌────▼──────────────▼──────────────▼──────┐ │
│ │ Supabase 用戶端 SDK │ │
│ └────────────────┬────────────────────────┘ │
└───────────────────┼───────────────────────────┘
│
┌──────────▼──────────┐
│ Supabase │
│ ┌──────────────┐ │
│ │ PostgreSQL │ │
│ │ + PostGIS │ │
│ ├──────────────┤ │
│ │ 身份驗證 │ │
│ ├──────────────┤ │
│ │ 儲存 │ │
│ ├──────────────┤ │
│ │ 即時 │ │
│ ├──────────────┤ │
│ │ Edge 函數 │ │
│ └──────────────┘ │
└─────────────────────┘
│
┌──────────▼──────────┐
│ 外部服務 │
│ • Stripe Connect │
│ • Mapbox/Google Maps│
│ • SendGrid/Resend │
│ • Cloudflare CDN │
└─────────────────────┘
關鍵的架構決策是對預訂創建和付款 webhook 等業務關鍵操作使用 Supabase Edge Functions,同時為搜索查詢和表單驗證等輕量級任務保留 Next.js API 路由。當 Stripe webhook 觸發時,這種分離很重要,你需要保證預訂狀態原子性地更新。
資料庫架構設計
這是大多數租賃平台在早期出錯並稍後為此付出代價的地方。以下是經歷過生產流量的架構:
-- 啟用 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'))
);
預訂表上的 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: '某些日期不可用' };
}
// 檢查現有預訂(既做和懸念的資料庫約束)
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: '日期已預訂' };
}
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('找不到物業');
// 獲取這些日期的任何價格覆蓋
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+ 中使用伺服器操作:
// 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('未驗證');
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 付款意圖
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: '這些日期剛被別人預訂。' };
}
throw error;
}
return {
bookingId: booking.id,
clientSecret: paymentIntent.client_secret,
};
}
注意我們如何處理 23P01 錯誤代碼——那是 PostgreSQL 的排斥違反。即使兩個用戶同時點擊「預訂」,也只有一個預訂通過。
使用 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+ 個列表上在不到 50 毫秒內運行,具有適當的索引。直到你達到更大的規模,才需要 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 最強的功能之一。安全規則與數據並排存在,而不是分散在 API 中間件中。
付款處理和結算
**使用 Stripe Connect。完全停止。**它處理市集付款、分割、1099、KYC 和國際結算。替代方案是構建你自己的付款分割系統,這涉及金錢轉帳許可證、KYC/AML 合規性、國際稅務報告和欺詐預防。Stripe 處理所有這一切。費用(大約每筆交易 3.2%)是值得的。一旦你處理了重要的交易量,你總是可以協商費率。
以下是流程:
- 主人通過 Stripe Connect Express 入職(Stripe 處理身份驗證 UI)
- 客人通過 Stripe 付款意圖支付
- 付款保留至入住 + 24 小時
- 結算轉賬給主人,減去你的平台費
2026 年 Stripe Connect 定價:除了標準處理費(2.9% + $0.30 每次收費)之外,每次結算 0.25% + $0.25。對於 $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,通過資料庫 webhook 從 Supabase Edge Functions 觸發。Resend 的定價從 $20/月開始,用於 50K 個電子郵件——足以容納不斷成長的平台。
影像處理和效能
物業照片構建或破壞轉化率。每個列表可能有 15-30 張影像,它們需要快速加載。
我的方法:
- 將原始上傳到 Supabase 儲存
- 使用 Supabase 的影像轉換 API 進行動態調整大小
- 通過 Next.js
<Image>元件提供適當的sizes和srcSet - 延遲載入摺疊線以下的所有內容
- 使用
blur佔位符,具有在上傳時生成的微小 base64 預覽
<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+ 張照片的列表頁面上提供不到一秒的 LCP。
部署和擴展考慮
對於部署,Vercel 是 Next.js 的自然選擇。但這裡有一個大多數文章跳過的細微差別:**謹慎為租賃平台使用 Vercel 的 Edge Runtime。**預訂流程需要 Node.js 執行時,用於 Stripe SDK 和複雜的資料庫操作。Edge 對於中間件(地理重定向、身份驗證檢查)很好,但對於業務邏輯不好。
| 部署選項 | 最適合 | 月度成本(估計) |
|---|---|---|
| Vercel Pro + Supabase Pro | MVP 至 10K MAU | $45 - $100 |
| Vercel Pro + Supabase Team | 10K-100K MAU | $200 - $500 |
| 自託管 Next.js + Supabase Pro | 成本最佳化 | $100 - $300 |
| AWS/GCP + 自託管 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 平台上構建進行比較,一旦你有任何真實牽引力,自訂開發的經濟開始變得有意義。
如果你認真構建租賃平台,想要有經驗的開發人員,他們已經在這個確切產品上交付了,與我們的團隊聯繫或查看我們的定價頁面以獲取項目估計。我們專門從事無頭 CMS 開發和複雜的 Next.js 應用程式。
常見問題
構建像 Airbnb 這樣的度假租賃平台需要多長時間? 具有列表、搜索、預訂、付款和訊息傳遞的功能性 MVP 需要一個熟練的 2-3 人開發人員團隊花費 3-5 個月。單獨的開發者可能需要 6-9 個月。這讓你上線——而不是與 Airbnb 的功能奇偶校驗,它擁有 15+ 年的開發成果。首先專注於你的利基功能。
Supabase 對於生產租賃平台來說足以擴展嗎? 是的,達到一定程度。Supabase Pro 輕鬆處理數萬個並發用戶。他們的 Team 計劃($599/月)支持更多。Instagram 多年來在單個 PostgreSQL 伺服器上執行。你的瓶頸將是產品市場契合度,遠在資料庫規模之前。當你確實超出 Supabase 時,你的數據就在標準 PostgreSQL 中——遷移很簡單。
你如何在度假租賃系統中防止重複預訂?
使用帶有 btree_gist 擴展的 PostgreSQL 排斥約束。這在資料庫級別強制執行,沒有兩個活動預訂可以對同一物業具有重疊的日期範圍。這是唯一可靠的方法——應用程式級檢查有競態條件。上面的架構示例顯示了如何實現這個。
我應該使用 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(漸進式 Web 應用程式)。新增推送通知、離線快取和「新增到主螢幕」提示。這涵蓋 80% 的行動使用情況。一旦你驗證了產品並擁有真實收入,投資於原生應用程式。許多成功的利基租賃平台(Hipcamp、Glamping Hub)首先啟動網路,稍後添加了原生應用程式。