如何在 Next.js ISR 上服務 137K 個清單而不爆炸 Vercel 預算
你的部署在晚上 11 點開始。你看著 Vercel 的構建日誌超過 10,000 個靜態路徑,然後 50,000 個,然後在接近 89,000 的地方停滯。六小時後,構建超時。你的 137,000 個清單目錄無法發佈,因為你試圖在構建時預先呈現所有內容 — 一個花費我們 11 天和一次非常尷尬的客戶電話的錯誤。我們最終發佈了一個生產系統,為數百萬次頁面瀏覽提供服務,排名為數千個長尾關鍵詞,並以每月 209 美元的成本按需重新生成頁面。使其成為可能的架構需要放棄我們預先呈現所有內容的本能,重新思考 Supabase 查詢在 ISR 下的擴展方式,以及一項 Vercel 配置更改,將響應時間減少了 340ms。以下是實際有效的方法。
該堆棧:Next.js 14(App Router)、Supabase(PostgreSQL + Edge Functions)、Vercel(託管 + ISR)和健康劑量的實用主義。我們犯了錯誤。我們遇到了牆。我們重寫了我們認為已完成的事情。但最終架構以全球 200ms 以下的 TTFB 處理 137,000 多個動態頁面,我們的 Supabase 帳單保持在每月 100 美元以下。
如果你正在構建類似的東西 — 市場、目錄、清單平台 — 這是我希望在我們開始時就存在的文章。
目錄
- 為什麼選擇此堆棧
- 數據層:大規模 Supabase
- 頁面生成策略:ISR、SSG 和 137K 問題
- URL 架構和規模化 SEO
- 搜索和過濾:困難的部分
- 性能預算和邊緣緩存
- 生產中的監控和可觀測性
- 成本明細:這實際上花費多少
- 我們會做什麼不同
- 常見問題

為什麼選擇此堆棧
在選擇 Next.js + Supabase + Vercel 之前,我們評估了許多選項。核心要求是:
- 137,000 多個唯一頁面搜索引擎可以抓取和索引
- 全球次秒級頁面加載(40 多個國家的用戶)
- 動態數據 — 清單每天更新,有些每小時更新
- 全文搜索具有分面過濾
- 預算意識 — 這不是一個風投資助的瘋狂想法
我們考慮了 Astro(非常適合靜態網站,但我們需要更多動態互動性 — 儘管我們的 Astro 開發團隊已使用它發佈了出色的目錄項目)。我們查看了 WordPress + WPEngine。我們短暫考慮了一個純 SPA 搭配 Algolia。
Next.js 贏得了一項殺手鐧功能:增量靜態再生成。ISR 意味著我們不必在靜態性能和動態內容之間進行選擇。我們可以同時擁有兩者。
Supabase 勝過 PlanetScale 和 Neon,因為它提供完整的軟件包 — auth、storage、邊緣函數和一個真正優秀的 Postgres 實現,具有行級安全性。對於目錄,你需要所有這些。
Vercel 是部署目標,因為 ISR 在 Vercel 上工作效果最好(不出所料)。集成是原生的。按需重新驗證直接運行。
自託管呢?
我們在 Railway 上原型化了一個自託管的 Next.js 設置。它有效,但自託管 Next.js 上的 ISR 有怪癖。緩存無效化故事更糟。你需要管理自己的 CDN 層。對於一個 3 名工程師的團隊,運營開銷不值得我們節省的 200 美元/月。
數據層:大規模 Supabase
我們的 Supabase 數據庫持有 137,000 個清單,每個清單有 40-60 個字段。類別、位置、聯繫信息、豐富描述、圖像、評分、營業時間 — 應有盡有。
模式設計
最大的決定是是否使用規範化關係模式或更以文檔為中心的方法,使用 JSONB 列。我們選擇了混合方法:
CREATE TABLE listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
category_id UUID REFERENCES categories(id),
city_id UUID REFERENCES cities(id),
country_code TEXT NOT NULL,
coordinates GEOGRAPHY(POINT, 4326),
contact JSONB DEFAULT '{}',
attributes JSONB DEFAULT '{}',
media JSONB DEFAULT '[]',
rating_avg NUMERIC(3,2) DEFAULT 0,
rating_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
published_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW(),
search_vector TSVECTOR
);
CREATE INDEX idx_listings_category ON listings(category_id) WHERE status = 'active';
CREATE INDEX idx_listings_city ON listings(city_id) WHERE status = 'active';
CREATE INDEX idx_listings_country ON listings(country_code) WHERE status = 'active';
CREATE INDEX idx_listings_coordinates ON listings USING GIST(coordinates);
CREATE INDEX idx_listings_search ON listings USING GIN(search_vector);
CREATE INDEX idx_listings_slug ON listings(slug);
結構化關係數據用於我們篩選的內容(類別、城市、國家)。JSONB 用於每個清單變化的半結構化內容(聯繫方式、自定義屬性、媒體數組)。這給了我們兩全其美 — 在關係列上進行快速索引查詢,在其餘部分上進行靈活性。
搜索向量
那個 search_vector 列是關鍵。我們用觸發器填充它:
CREATE OR REPLACE FUNCTION update_search_vector()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.attributes->>'keywords', '')), 'C');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
這意味著每個清單都可以通過 Postgres 本身進行全文搜索。前 100K 個清單不需要外部搜索服務。我們稍後會談論何時會出問題。
連接池
Supabase 使用 PgBouncer 進行連接池。使用 ISR,你會遇到無服務器函數調用的突發 — 每一個都需要一個數據庫連接。沒有池,你會在幾分鐘內耗盡連接。
我們將池連接字符串(port 6543)用於所有無服務器上下文,直接連接(port 5432)僅用於遷移和管理任務。這是那些聽起來很明顯但會難住人的事情之一。
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // Server-side only
{
db: { schema: 'public' },
auth: { persistSession: false }
}
)
頁面生成策略:ISR、SSG 和 137K 問題
這是事情變得有趣的地方。也是我們犯最大早期錯誤的地方。
天真的方法(不要這樣做)
我們的第一次嘗試:在構建時使用 generateStaticParams 生成所有 137,000 個頁面。構建花了 4 小時 22 分鐘。Vercel 免費層的構建限制是 45 分鐘。即使是專業層也限制在 6 小時。但真正的問題不是超時 — 是反饋循環。每次部署花了半天。那是不可行的。
ISR 方法(實際有效的方法)
以下是發佈的策略:
- 在構建時:按流量靜態生成前 5,000 個頁面
- 在首次請求時:按需生成剩餘頁面並緩存它們
- 重新驗證:基於時間(每 3600 秒)+ 通過 webhook 按需
// app/listing/[slug]/page.tsx
import { supabase } from '@/lib/supabase'
import { notFound } from 'next/navigation'
export async function generateStaticParams() {
// Only pre-generate top listings by traffic
const { data } = await supabase
.from('listings')
.select('slug')
.eq('status', 'active')
.order('rating_count', { ascending: false })
.limit(5000)
return (data || []).map((listing) => ({
slug: listing.slug,
}))
}
export const revalidate = 3600 // Revalidate every hour
export default async function ListingPage({ params }: { params: { slug: string } }) {
const { data: listing, error } = await supabase
.from('listings')
.select(`
*,
category:categories(*),
city:cities(*, country:countries(*))
`)
.eq('slug', params.slug)
.eq('status', 'active')
.single()
if (!listing || error) notFound()
return <ListingDetail listing={listing} />
}
按需重新驗證
當清單所有者更新他們的數據時,我們不想等待最多一小時才能刷新頁面。Supabase webhook 觸發一個 Next.js API 路由:
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidation-secret')
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { slug, type } = await request.json()
if (type === 'listing') {
revalidatePath(`/listing/${slug}`)
revalidatePath(`/`) // Revalidate homepage too
}
return NextResponse.json({ revalidated: true })
}
這給了我們兩全其美:靜態站點性能和動態站點新鮮度。構建在 8 分鐘內完成。未預先生成的頁面在首次訪問時創建並在邊緣緩存。
數字
| 指標 | 完整 SSG(天真) | ISR(生產) | |--------|-----------------|-------------------|| | 構建時間 | 4 小時 22 分 | 7 分 40 秒 | | 部署時的頁面 | 137,000 | 5,000 | | 首次訪問(未緩存) | 不適用 | 約 800ms | | 後續訪問 | 約 120ms | 約 120ms | | 重新驗證延遲 | 完整重新部署 | < 2 秒 | | 月度構建分鐘數 | 遠超限制 | 約 230 分鐘 |

URL 架構和規模化 SEO
擁有 137,000 個頁面,URL 結構不是事後考慮 — 它是架構。每個 URL 都是一個排名機會。
URL 層次結構
/ → 主頁
/categories/[category-slug] → 分類頁面(48 個分類)
/locations/[country]/[city] → 位置頁面
/listing/[listing-slug] → 個別清單
/search?q=...&category=...&city=... → 搜索結果(noindex)
分類 + 位置交集頁面是真正的 SEO 金礦:
/categories/restaurants/us/new-york → "紐約的餐廳"
/categories/hotels/uk/london → "倫敦的酒店"
這些交集頁面使用 ISR 動態生成。大約有 12,000 個有效組合。每一個都針對特定的長尾關鍵詞。
站點地圖生成
有 137K 個 URL,你需要站點地圖索引文件。Google 的限制是每個站點地圖 50,000 個 URL。
// app/sitemap/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
const page = parseInt(params.id)
const perPage = 45000 // Stay under the 50K limit
const offset = page * perPage
const { data: listings } = await supabase
.from('listings')
.select('slug, updated_at')
.eq('status', 'active')
.order('id')
.range(offset, offset + perPage - 1)
const xml = generateSitemapXml(listings)
return new Response(xml, {
headers: { 'Content-Type': 'application/xml' },
})
}
我們分為 4 個站點地圖:sitemap-0.xml 到 sitemap-3.xml,由站點地圖索引參考。Google Search Console 在 6 週內索引了 98% 的提交 URL。
結構化數據
每個清單頁面都包含 JSON-LD 結構化數據。對於目錄,LocalBusiness 模式是關鍵:
const structuredData = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: listing.title,
description: listing.description,
address: {
'@type': 'PostalAddress',
addressLocality: listing.city.name,
addressCountry: listing.city.country.code,
},
geo: {
'@type': 'GeoCoordinates',
latitude: listing.coordinates?.lat,
longitude: listing.coordinates?.lng,
},
aggregateRating: listing.rating_count > 0 ? {
'@type': 'AggregateRating',
ratingValue: listing.rating_avg,
reviewCount: listing.rating_count,
} : undefined,
}
搜索和過濾:困難的部分
搜索總是困難的部分。總是。
階段 1:Postgres 全文搜索
對於我們最初的發佈,Postgres tsvector 搜索處理了所有事情。它對 137K 行的 GIN 索引足夠快。查詢時間平均 40-80ms。
const { data } = await supabase
.from('listings')
.select('id, slug, title, description, category:categories(name)')
.textSearch('search_vector', query, { type: 'websearch' })
.eq('status', 'active')
.eq('country_code', countryFilter)
.order('rating_avg', { ascending: false })
.range(0, 19)
階段 2:當 Postgres 不夠時
在大約 80,000 個清單處,複雜的分面搜索(分類 + 位置 + 文本 + 排序)開始達到 300-500ms。對大多數應用來說是可接受的,但我們的用戶期望即時結果。
我們添加了 Typesense 作為搜索層。不是 Algolia(在我們的規模下太貴 — 我們的支付將超過 500 美元/月)。不是 Meilisearch(很好,但 Typesense 的地理搜索對我們的用例更好)。
Typesense 在單個 48 美元/月的 Hetzner 實例上運行。通過每晚完整重新索引 + 實時 webhook 更新從 Supabase 同步。搜索查詢現在平均 8-15ms。
| 搜索解決方案 | 查詢時間 (p50) | 查詢時間 (p99) | 月度成本 | 分面搜索 |
|---|---|---|---|---|
| Postgres FTS | 45ms | 320ms | 0 美元(包含) | 有限 |
| Typesense | 9ms | 28ms | 48 美元 | 優秀 |
| Algolia | 約 5ms | 約 15ms | 500 美元+ | 優秀 |
| Meilisearch | 約 8ms | 約 22ms | 48 美元(自託管) | 良好 |
性能預算和邊緣緩存
我們從一開始就設定了激進的性能目標:
- TTFB:< 200ms(全球 p75)
- LCP:< 1.5s
- CLS:< 0.05
- 總頁面重量:< 300KB(初始加載)
Vercel 邊緣網絡
ISR 頁面在 Vercel 的邊緣網絡上被緩存 — 全球 100 多個 PoP。一旦頁面被生成並緩存,它就從最近的邊緣位置提供。這就是為什麼即使對於東南亞或南美洲的用戶,TTFB 也保持在 200ms 以下。
圖像優化
每個清單有 1-8 張圖像。這可能是超過 100 萬張圖像。我們使用 Vercel 的內置圖像優化與 next/image:
<Image
src={listing.media[0]?.url}
alt={listing.title}
width={800}
height={600}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
loading={index === 0 ? 'eager' : 'lazy'}
quality={75}
/>
圖像存儲在 Supabase Storage 中,並通過 Vercel 的圖像 CDN 提供。原始圖像通常是 2-5MB;優化後,它們是 40-120KB。這本身節省了我們大約 80% 的帶寬。
生產中的監控和可觀測性
在沒有監控的情況下運行 137K 頁面的生產就像蒙著眼睛開車。這是我們的堆棧:
- Vercel Analytics:Core Web Vitals、真實用戶監控
- Sentry:錯誤追蹤(我們每天捕獲約 50 個錯誤,主要來自發送垃圾的機器人)
- Supabase Dashboard:數據庫性能、查詢分析
- Checkly:合成監控,每 5 分鐘在關鍵路徑上進行間隔
- Google Search Console:索引覆蓋、抓取統計
我們設置的最有價值的監控是一個每日 Supabase 查詢,計算索引頁面數與總活躍清單數。如果比率降低到 95% 以下,我們會得到警報。這在部署壞更改後 24 小時內發現了站點地圖回歸。
成本明細:這實際上花費多少
人們總是問關於成本。以下是 Q1 2026 年的實際月度支出:
| 服務 | 計劃 | 月度成本 |
|---|---|---|
| Vercel | Pro | 20 美元 |
| Vercel 帶寬(超額) | 按用量付費 | 約 35 美元 |
| Supabase | Pro | 25 美元 |
| Supabase 數據庫(計算) | 小實例 | 48 美元 |
| Typesense(Hetzner) | CX31 | 48 美元 |
| Checkly | 入門級 | 7 美元 |
| Sentry | 團隊 | 26 美元 |
| 域 + DNS(Cloudflare) | 免費層 | 0 美元 |
| 合計 | 約 209 美元/月 |
服務 137,000 個頁面,每月數百萬次頁面瀏覽,成本約 200 美元/月。嘗試用運行 WordPress 的傳統服務器設置做到這一點。
如果你正在考慮類似的項目,並想了解這樣的架構如何映射到你的預算,我們的定價頁面詳細說明了我們通常如何確定目錄和市場項目的範圍。
我們會做什麼不同
**從第一天就開始使用 ISR。**我們浪費了兩週時間試圖讓完整 SSG 工作,然後才接受數學不成立。
**從一開始就使用 Typesense。**Postgres FTS 在早期很好,但在項目中途遷移搜索是破壞性的。48 美元/月從發佈開始就值得。
**更早投資於數據驗證。**有 137K 個從各種來源導入的清單,數據質量是一場噩夢。我們應該在第一次導入前構建更嚴格的 Zod 模式和驗證管道,而不是在生產中發現數千條破損記錄後。
**在測試中使用現實的數據量。**我們的測試環境有 500 個清單。在 500 行中工作得很好的查詢在 137K 時崩潰了。我們現在用 20% 的生產數據隨機樣本為測試環境播種。
如果你正在規劃目錄或市場構建,並希望避免這些相同的陷阱,請與我們的團隊聯繫。我們已經經歷過這個足夠多次,知道地雷在哪裡。
常見問題
使用 Next.js 構建 100K+ 清單目錄需要多長時間? 對於我們的團隊,初始架構和核心功能花了大約 10 週。數據導入、清理和驗證增加了另外 3-4 週。從開始到生產發佈的總時間大約 14 週。如果你與已經做過這項工作的 Next.js 開發團隊合作,你可以減少 2-3 週。
Supabase 能為目錄處理 100,000 多行嗎? 絕對可以。Supabase 在 Postgres 上運行,可以處理數百萬行而無需費力。關鍵是適當的索引 — 沒有在最常查詢的列上建立索引,性能會迅速下降。使用我們上面描述的索引,我們在 137K 行上的查詢始終在 50ms 以內返回用於單記錄查詢。
ISR 和 SSG 對於大型網站有什麼區別? SSG(靜態站點生成)在部署時構建每個頁面。ISR(增量靜態再生成)在部署時構建一個子集,按需生成其餘部分。對於超過約 10,000 個頁面的網站,ISR 實際上是必需的 — 完整 SSG 構建變得太慢,無法合理的部署周期。
如何為 137,000 個動態生成的頁面處理 SEO? 三件事最重要:跨多個文件拆分的正確站點地圖生成,每個清單頁面上的唯一結構化數據(JSON-LD),以及確保 ISR 生成的頁面返回正確的 HTTP 200 狀態代碼(不是軟 404)。我們還使用清單數據為每個頁面生成唯一的元標題和描述 — 沒有重複的元內容。
Vercel ISR 對於規模化生產是否可靠? 根據我們的經驗,是的。我們已運行此設置超過 8 個月,正常運行時間為 99.98%。唯一的事件是自我造成的 — 破壞我們重新驗證 webhook 的壞部署,以及一個 Supabase 維護窗口導致 15 分鐘搜索性能下降。Vercel 的邊緣緩存非常可靠。
對於大型目錄,我應該使用 Algolia 還是 Typesense? 這取決於你的預算。Algolia 是行業標準,開發者體驗最佳,但超過 100K 記錄後變得昂貴 — 預期 500-1000 美元+/月。Typesense 在自託管時以成本的一部分提供 90% 的功能。我們選擇了 Typesense,並且沒有後悔。
你如何保持 137,000 個清單的最新狀態? 我們使用組合方法:當各個清單更改時由 Supabase webhook 觸發的按需重新驗證,作為安全網的基於時間的 ISR 重新驗證(每小時),以及一個每晚批處理作業,檢查過時數據並觸發批量重新驗證。清單所有者也可以通過他們的儀表板手動請求頁面刷新。
這個架構可以使用無頭 CMS 而不是 Supabase 嗎? 可以,但有權衡。一個無頭 CMS 設置,如 Sanity 或 Contentful,適用於內容管理方面,但你可能仍然需要一個數據庫用於搜索和複雜查詢。我們已構建了目錄項目,其中編輯內容位於無頭 CMS,清單數據位於 Postgres — 這是一個有效的混合方法。