使用 Next.js 和 Supabase 建立度假租賃平台
在過去的18個月裡,我幫助兩個不同的客戶建立度假租賃平台。不是玩具項目——真正的業務,擁有數千個列表、支付處理和那種會讓你在凌晨2點懷疑職業選擇的預訂邏輯。以下是我對使用Next.js和Supabase構建Airbnb風格平台的學習,以及為什麼這個堆棧對於2025年進入短期租賃領域的初創企業確實可行。
度假租賃市場預計到2027年將達到1139億美元(Statista)。Airbnb佔據了巨大的份額,但利基平台——寵物友善住宿、豪華別墅、鄉村度假村、衝浪屋——正在蓬勃發展,因為它們比通用平台更好地服務特定受眾。你不需要打敗Airbnb。你需要在你的利基市場中超越他們的服務。
目錄
- 為什麼使用Next.js + Supabase建設租賃平台
- 系統架構概述
- 數據庫架構設計
- 構建預訂引擎
- 使用PostGIS的搜尋和過濾
- 身份驗證和多角色訪問
- 支付處理和支出
- 實時訊息和通知
- 圖像處理和性能
- 部署和擴展考慮
- 成本分解:你實際花費的費用
- 常見問題

為什麼使用Next.js + Supabase建設租賃平台
讓我直言不諱:你可以用幾十種不同的堆棧來構建這個。Laravel、Rails、Django——所有不錯的選擇。但Next.js + Supabase組合對於租賃平台特別來說點擊了一個甜點。
Next.js為你提供:
- 用於SEO的服務端渲染(列表頁面需要排名)
- 帶有React服務器組件的App Router,快速初始加載
- 不需要單獨服務器的後端邏輯API路由
- 內置圖像優化(對於財產照片至關重要)
- 增量靜態再生成,用於很少改變的列表頁面
Supabase為你提供:
- 帶PostGIS的PostgreSQL用於地理查詢(「顯示我20公里內的租賃」)
- 實際適用於多租戶應用的行級安全(RLS)
- 用於訊息和預訂更新的實時訂閱
- 內置OAuth提供商認證
- 用於財產圖像和CDN交付的存儲
- 用於無伺服器業務邏輯的邊界函數
真正的殺手級功能是Supabase的底層是Postgres。當你超越Supabase的託管服務(或需要超越時),你可以遷移到任何Postgres主機。在你最關鍵的資產——你的數據上沒有供應商鎖定。
如果你正在評估框架,我們的Next.js開發團隊已在這個確切的堆棧上交付了多個平台。
系統架構概述
以下是在多個項目中表現良好的高級架構:
┌─────────────────────────────────────────────┐
│ Next.js應用程序 │
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
│ │ 頁面 │ │ API │ │ 服務器 │ │
│ │(SSR/ISR)│ │ 路由 │ │ 組件 │ │
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌────▼──────────────▼──────────────▼──────┐ │
│ │ Supabase客戶端SDK │ │
│ └────────────────┬────────────────────────┘ │
└───────────────────┼───────────────────────────┘
│
┌──────────▼──────────┐
│ Supabase │
│ ┌──────────────┐ │
│ │ PostgreSQL │ │
│ │ + PostGIS │ │
│ ├──────────────┤ │
│ │ 認證 │ │
│ ├──────────────┤ │
│ │ 存儲 │ │
│ ├──────────────┤ │
│ │ 實時 │ │
│ ├──────────────┤ │
│ │ 邊界函數 │ │
│ └──────────────┘ │
└─────────────────────┘
│
┌──────────▼──────────┐
│ 外部服務 │
│ • Stripe Connect │
│ • Mapbox/Google Maps│
│ • SendGrid/Resend │
│ • Cloudflare CDN │
└─────────────────────┘
關鍵的架構決策是對預訂創建和支付webhook等業務關鍵操作使用Supabase邊界函數,同時為搜索查詢和表單驗證等輕量級任務保持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(),
-- 在DB級別防止雙重預訂
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: '某些日期不可用' };
}
// 檢查現有預訂(與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: '日期已預訂' };
}
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: '2025-08-01',
p_check_out: '2025-08-07',
});
使用適當的索引,它對100K+列表的運行時間不到50毫秒。不需要Elasticsearch直到你達到更大的規模。
身份驗證和多角色訪問
Supabase認證處理繁重的工作。棘手的部分是租賃平台的雙重角色性質——有人可以既是客人又是主人。
我用配置文件上的角色字段來處理這個問題,當他們創建第一個列表時從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。句號。 它處理市場支付、分割、1099s、KYC和國際支付。替代方案是構建你自己的貨幣傳輸系統,這是...別做。
流程是:
- 主人通過Stripe Connect Express登録(Stripe處理身份驗證UI)
- 客人通過Stripe支付意圖支付
- 支付在入住+24小時後保留
- 支付轉移到主人減去你的平台費用
Stripe Connect 2025年的定價:每次支付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();
對於電子郵件通知(預訂確認、入住提醒),我使用從Supabase邊界函數通過數據庫webhook觸發的Resend或SendGrid。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的邊界運行時。 預訂流需要Node.js運行時用於Stripe SDK和複雜的數據庫操作。邊界非常適合中間件(地理重定向、認證檢查),但不適合業務邏輯。
| 部署選項 | 最適合 | 月度成本(估計) |
|---|---|---|
| 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和早期平台。
成本分解:你實際花費的費用
以下是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應用程序。
常見問題
構建像Airbnb這樣的度假租賃平台需要多長時間? 一個具有列表、搜尋、預訂、支付和訊息的功能MVP需要3-5個月,配備2-3名開發人員的熟練團隊。獨立開發人員可能需要6-9個月。這讓你上線——而不是與Airbnb功能對等,它有15年以上的開發歷史。首先專注於你的利基功能。
Supabase對於生產租賃平台可擴展嗎? 是的,達到一定程度。Supabase Pro舒適地處理數以萬計的併發用戶。他們的團隊計劃($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每1000個動態地圖負載收費$7(在$200月度額度之後)。
我如何處理季節性定價和動態費率? 在基礎財產價格旁邊使用基於日期的可用性/定價覆蓋表。對於預訂的每一晚,檢查該特定日期是否有價格覆蓋。如果沒有,回到基礎價格。這處理季節性費率、週末溢價、假日定價和最後一刻折扣。一些平台還集成PriceLabs($19.99/列表/月)或Beyond Pricing進行自動動態定價。
Next.js對於租賃平台比Astro更好嗎? 對於具有交互式預訂流、訊息和儀表板的完整租賃平台——Next.js獲勝。應用需要重要的客戶端交互性。Astro擅長內容豐富的網站,交互性極少(查看我們的Astro開發功能)。也就是說,如果你正在構建僅列表網站而不預訂(如目錄),Astro的性能將是傑出的。
關於移動應用程序——我是否也需要React Native? 不是你的MVP。首先構建Next.js應用作為響應式PWA(進步式網應用)。添加推送通知、離線緩存和「添加到主屏幕」提示。這涵蓋80%的移動用例。一旦你驗證了產品並有真實收入,投資原生應用。許多成功的利基租賃平台(Hipcamp、Glamping Hub)首次啟動網絡並稍後添加原生應用。