在过去的 18 个月里,我帮助两个不同的客户构建了度假租赁平台。这些不是玩具项目 — 而是拥有数千个列表、支付处理和会让你在凌晨 2 点质疑职业选择的预订逻辑的真实业务。以下是我关于如何使用 Next.js 和 Supabase 构建类似 Airbnb 的平台的学习,以及为什么这个技术栈对于 2025 年进入短期租赁领域的初创公司来说是真正可行的。

度假租赁市场预计到 2027 年将达到 1139 亿美元(Statista)。Airbnb 占据了大块市场,但利基平台 — 宠物友好住宿、豪华别墅、乡村度假、冲浪屋 — 因为它们比通才更好地服务特定受众而蓬勃发展。你不需要击败 Airbnb。你需要在你的利基市场中为他们服务得更好。

目录

使用 Next.js 和 Supabase 构建度假租赁平台

为什么选择 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;

使用 Next.js 和 Supabase 构建度假租赁平台 - 架构

构建预订引擎

预订流程是任何租赁平台的核心。以下是我如何构造它的方式:

第 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%)是值得的。一旦你处理大量交易,你总是可以谈判费率。

以下是流程:

  1. 主人通过 Stripe Connect Express 上线(Stripe 处理身份验证 UI)
  2. 客人通过 Stripe 支付意图支付
  3. 入住前 24 小时保持支付
  4. 支付转账至主人减去你的平台费用

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> 组件使用正确的 sizessrcSet 提供服务
  • 懒加载折叠线下的所有内容
  • 在上传时使用 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)首先启动网络,稍后添加了本地应用。