使用 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 Server Components 的 App Router 以加快初始加载速度
- 用于后端逻辑的 API 路由,无需单独的服务器
- 内置图像优化(对于房产照片至关重要)
- 增量静态再生成用于很少更改的列表页面
Supabase 提供了:
- 带有 PostGIS 的 PostgreSQL,用于地理查询("显示我在 20 公里范围内的租赁")
- 行级安全性 (RLS),真正适用于多租户应用
- 用于消息传递和预订更新的实时订阅
- 内置 OAuth 提供者的身份验证
- 用于房产图像和 CDN 交付的存储
- 用于无服务器业务逻辑的 Edge Functions
真正的杀手锏功能是 Supabase 在底层就是 Postgres。当你超越 Supabase 的托管服务(或需要超越时),你可以迁移到任何 Postgres 主机。你最关键资产 — 你的数据 — 上没有供应商锁定。
如果你在评估框架,我们的 Next.js 开发团队 已经在这个确切的技术栈上发布了几个平台。
系统架构概览
以下是在多个项目中效果很好的高级架构:
┌─────────────────────────────────────────────┐
│ Next.js 应用 │
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
│ │ Pages │ │ API │ │ Server │ │
│ │ (SSR/ISR)│ │ Routes │ │ Components │ │
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌────▼──────────────▼──────────────▼──────┐ │
│ │ Supabase Client SDK │ │
│ └────────────────┬────────────────────────┘ │
└───────────────────┼───────────────────────────┘
│
┌──────────▼──────────┐
│ Supabase │
│ ┌──────────────┐ │
│ │ PostgreSQL │ │
│ │ + PostGIS │ │
│ ├──────────────┤ │
│ │ Auth │ │
│ ├──────────────┤ │
│ │ Storage │ │
│ ├──────────────┤ │
│ │ Realtime │ │
│ ├──────────────┤ │
│ │ Edge Funcs │ │
│ └──────────────┘ │
└─────────────────────┘
│
┌──────────▼──────────┐
│ 外部服务 │
│ • 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: '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。句号。 它处理市场支付、拆分、1099 表、KYC 和国际支付。替代方案是构建自己的支付分割系统,这涉及货币转账许可证、KYC/AML 合规性、国际税报、和欺诈防止。Stripe 处理所有这些。费用(大约每笔交易 3.2%)是值得的。一旦你处理大量交易,你总是可以谈判费率。
以下是流程:
- 主人通过 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 Edge Functions 通过数据库 webhook 触发的 Resend 或 SendGrid。Resend 的定价从 $20/月起,用于 50K 封电子邮件 — 对于成长中的平台来说已足够。
图像处理和性能
房产照片决定了转化率。每个列表可能有 15-30 张图片,它们需要快速加载。
我的方法:
- 上传原始文件到 Supabase Storage
- 使用 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 和早期平台来说已足够。
成本分解:你实际会花多少钱
以下是在 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 个月。这让你达到发布 — 而不是与有 15+ 年开发历史的 Airbnb 功能相当。首先专注于你的利基功能。
Supabase 对于生产租赁平台的可扩展性足够吗? 是的,在某个点。Supabase Pro 可舒适处理数万并发用户。他们的团队计划($599/月)支持更多的同时用户。Instagram 多年来在单个 PostgreSQL 服务器上运行。你的瓶颈将是产品市场契合度,远在数据库规模之前。当你确实超越 Supabase 时,你的数据是标准 PostgreSQL — 迁移是直接的。
如何在度假租赁系统中防止重复预订?
使用 PostgreSQL 排斥约束与 btree_gist 扩展。这在数据库级别强制执行,相同房产没有两个活动预订可以有重叠的日期范围。这是唯一可靠的方法 — 应用级别的检查有竞争条件。上面的架构示例显示了如何完全实现这个。
我应该使用 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)首先启动网络,稍后添加了本地应用。