你的訪客在可用性日曆停滯三秒鐘時滾過物業 #47。他們關閉標籤頁。那是 $180 的傭金,你永遠看不到——這發生是因為你的預訂表在時區轉換期間被鎖定了。我親眼見過這種情況在兩個實時度假租賃平台上上演,都是用 Next.js 和 Supabase 構建的,都通過 Stripe Connect 處理真實付款。一個在八個月內達到 3,000 個列表。另一個在 400 個時幾乎崩潰,因為創始人跳過了行級安全性,讓客人看到彼此的預訂歷史。區別不是天賦或預算——這是在第二週做出的資料庫架構決策。以下是經歷過生存的架構、防止週六晚上 11 點雙重預訂的 Supabase 觸發器,以及為什麼這個堆棧比每個人仍然推薦的 Rails 單體處理短期租賃複雜性更好。

度假租賃市場預計到 2027 年將達到 1,139 億美元(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 應用程式              │
│  ┌─────────┐  ┌──────────┐  ┌────────────┐  │
│  │  頁面    │  │   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;

使用 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: '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%)是值得的。一旦你處理了重要的交易量,你總是可以協商費率。

以下是流程:

  1. 主人通過 Stripe Connect Express 入職(Stripe 處理身份驗證 UI)
  2. 客人通過 Stripe 付款意圖支付
  3. 付款保留至入住 + 24 小時
  4. 結算轉賬給主人,減去你的平台費

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> 元件提供適當的 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 和早期平台。

成本分解:你實際花費的費用

以下是 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)首先啟動網路,稍後添加了原生應用程式。