使用 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(列表页面需要排名)
- App Router 与 React Server Components 以实现快速初始加载
- 无需单独服务器的后端逻辑 API 路由
- 内置图像优化(对于房产照片至关重要)
- 用于很少变化的列表页面的增量静态再生成
Supabase 为您提供:
- 带有 PostGIS 的 PostgreSQL 用于地理查询("显示我 20 公里内的租赁")
- 实际适用于多租户应用的行级安全 (RLS)
- 用于消息和预订更新的实时订阅
- 内置带 OAuth 提供商的身份验证
- 用于房产图像与 CDN 交付的存储
- 用于无服务器业务逻辑的边缘函数
真正的杀手级功能是 Supabase 实际上就是 Postgres。当您超越 Supabase 的托管服务(或需要时),您可以迁移到任何 Postgres 主机。您最关键的资产上没有供应商锁定——您的数据。
如果您正在评估框架,我们的 Next.js 开发团队 已在这个确切堆栈上交付了多个平台。
系统架构概览
这是在多个项目中表现良好的高级架构:
┌─────────────────────────────────────────────┐
│ Next.js 应用 │
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
│ │ 页面 │ │ API │ │ 服务端 │ │
│ │ (SSR/ISR)│ │ 路由 │ │ 组件 │ │
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌────▼──────────────▼──────────────▼──────┐ │
│ │ Supabase 客户端 SDK │ │
│ └────────────────┬────────────────────────┘ │
└───────────────────┼───────────────────────────┘
│
┌──────────▼──────────┐
│ Supabase │
│ ┌──────────────┐ │
│ │ PostgreSQL │ │
│ │ + PostGIS │ │
│ ├──────────────┤ │
│ │ Auth │ │
│ ├──────────────┤ │
│ │ Storage │ │
│ ├──────────────┤ │
│ │ Realtime │ │
│ ├──────────────┤ │
│ │ 边缘函数 │ │
│ └──────────────┘ │
└─────────────────────┘
│
┌──────────▼──────────┐
│ 外部服务 │
│ • Stripe Connect │
│ • Mapbox/Google Maps│
│ • SendGrid/Resend │
│ • Cloudflare CDN │
└─────────────────────┘
关键的架构决策是使用 Supabase 边缘函数 处理业务关键操作,如预订创建和支付 webhook,同时为轻任务(如搜索查询和表单验证)保留 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: '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 对于多租户应用的最强特性之一。安全规则与数据相邻,而不是分散在 API 中间件中。
支付处理和支付
使用 Stripe Connect。完全。 它处理市场支付、分割、1099s、KYC 和国际支付。替代方案是构建您自己的支付分割系统,这是...别这样做。
这是流程:
- 主人通过 Stripe Connect Express 进行注册(Stripe 处理身份验证 UI)
- 客人通过 Stripe 支付意向支付
- 支付保留到入住 + 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();
对于电子邮件通知(预订确认、入住提醒),我使用从 Supabase 边缘函数通过数据库 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 的边缘运行时。 预订流程需要 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 和早期阶段平台。
成本分解:您实际花费多少
以下是在 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 平台上构建相比,一旦您有任何真实的动力,定制开发的经济效益就开始有意义了。
如果您认真考虑构建度假租赁平台并想要有经验的开发人员,他们已经在这个确切的产品类型上交付了,与我们的团队联系或查看我们的定价页面以获取项目估计。我们专门从事 headless CMS 开发和复杂的 Next.js 应用。
常见问题
构建像 Airbnb 这样的度假租赁平台需要多长时间? 具有列表、搜索、预订、支付和消息传递的功能 MVP 需要 3-5 个月,具有 2-3 个开发人员的技能团队。单个开发人员可能需要 6-9 个月。这让您启动——而不是与拥有 15+ 年开发历史的 Airbnb 的功能奇偶校验。首先关注您的利基功能。
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 在每月 $200 信用后,每 1000 次动态地图加载收费 $7。
我如何处理季节性定价和动态费率? 使用基本属性价格旁的基于日期的可用性/定价覆盖表。对于预订的每个晚上,检查该特定日期是否有价格覆盖。如果没有,回退到基础价格。这处理季节费率、周末溢价、假日定价和最后一刻折扣。一些平台还与 PriceLabs($19.99/列表/月)或 Beyond Pricing 集成以进行自动动态定价。
Next.js 对于租赁平台比 Astro 更好吗? 对于具有交互式预订流程、消息传递和仪表板的完整租赁平台——Next.js 胜出。应用需要大量客户端交互。Astro 在内容丰富、交互最少的网站中表现出色(检查我们的 Astro 开发能力)。也就是说,如果您正在构建仅列表网站而不预订(如目录),Astro 的性能会非常出色。
关于移动应用——我是否需要 React Native? 不是为了您的 MVP。首先构建 Next.js 应用为响应式 PWA(渐进式网络应用)。添加推送通知、离线缓存和"添加到主屏幕"提示。这涵盖了 80% 的移动用例。一旦您验证了产品并拥有真实收入,投资本机应用。许多成功的利基租赁平台(Hipcamp、Glamping Hub)以网络优先启动,稍后添加了本机应用。