我們如何使用 Next.js 和 Vercel ISR 建立 137K 上市目錄平台
我們如何使用 Next.js 和 Vercel ISR 建立了一個 137K 列表目錄平台
去年,我們推出了一個目錄平台。137,000 個列表。這不是什麼小事。這是一個完全實現的平台,每個列表都有自己的 SEO 優化頁面。搜尋需要快速響應,是的,託管必須保持負擔得起。那麼,我們如何用 Next.js、Vercel 和增量靜態再生成 (ISR) 做到這一點的呢?準備好了;這是故事,包括事情變得棘手的地方。

目錄
- 為什麼目錄平台比看起來要難
- 架構概述
- 在規模上實際有效的 ISR 策略
- 處理 137K 個頁面而不會爆炸構建時間
- 數據庫和搜尋層
- 規模上的 SEO:網站地圖、結構化數據和爬蟲預算
- 性能基準
- Vercel 上的成本分解
- 我們犯的錯誤和我們會改變什麼
- 常見問題
為什麼目錄平台比看起來要難
目錄網站可能看起來很直接。你可能認為一個列表頁面、一個詳細頁面、撒一些篩選器,瞧!完成了。但一旦你超過幾千個列表,一切都陷入了複雜性。
這裡真正發生的是:
- 137,000+ 個唯一頁面,每個都必須可爬取且可索引
- 分面搜尋涵蓋位置、類別等
- 管理陳舊數據 -- 列表處於不斷變化的狀態,有更新和移除
- SEO 需求意味著你不能只依賴客戶端渲染
- 預算託管消除了在構建時生成所有頁面的可能性
在檢查了一堆方法後,我們確定了 Next.js 和 ISR 作為我們的首選。我們確實也考慮過 Astro(用於我們的一些其他工作--見我們的 Astro 開發工作)。最終,Next.js 的動態能力與 ISR 是明顯的選擇。
架構概述
這是我們的架構看起來的樣子:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Vercel │────▶│ Next.js App │────▶│ PostgreSQL │
│ Edge CDN │ │ (ISR) │ │ (Neon) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Redis │ │ Meilisearch │
│ (Upstash) │ │ (Cloud) │
└──────────────┘ └──────────────┘
技術棧
| 組件 | 技術 | 原因 |
|---|---|---|
| 框架 | Next.js 14 (App Router) | ISR 支持、React Server Components、路由處理器 |
| 託管 | Vercel Pro | Edge CDN、ISR 基礎設施、分析 |
| 數據庫 | Neon PostgreSQL | 無伺服器 Postgres、分支用於預覽 |
| 搜尋 | Meilisearch Cloud | 容錯搜尋、分面搜尋、快速索引 |
| 快取 | Upstash Redis | 速率限制、會話快取、ISR 協調 |
| CMS (admin) | 自訂 admin + Payload CMS | 列表管理、批量操作 |
| CDN/圖像 | Vercel Image Optimization + Cloudinary | 多個斷點的列表照片 |
這是一個核心上的 Next.js 開發項目,ISR 是我們的大賣點。

在規模上實際有效的 ISR 策略
讓我們開門見山:如果你試圖在構建時靜態生成 137,000 個頁面,你會自尋煩惱。認真地說,不要邀請那種頭痛。即使使用 Next.js 的並行生成,構建可能超過 45 分鐘,將每次部署變成一場噩夢。
ISR 讓你根據需要生成頁面並在邊緣快取它們。默認的 ISR 很好,但對於我們,調整是必要的。
三層頁面策略
我們將列表分為三個層級:
// app/listing/[slug]/page.tsx
export async function generateStaticParams() {
// 第 1 層:預生成排名前 2,000 的高流量列表
const topListings = await db.listing.findMany({
where: { tier: 'premium' },
orderBy: { monthlyViews: 'desc' },
take: 2000,
select: { slug: true },
});
return topListings.map((listing) => ({
slug: listing.slug,
}));
}
// 第 2 層和第 3 層:通過 ISR 按需生成
export const revalidate = 3600; // 大多數列表 1 小時
export default async function ListingPage({ params }: { params: { slug: string } }) {
const listing = await getListingBySlug(params.slug);
if (!listing) {
notFound();
}
// 基於列表層級的動態再驗證
// 優質列表每 10 分鐘再驗證一次
// 標準列表每小時一次
// 已存檔列表每 24 小時一次
return <ListingDetail listing={listing} />;
}
第 1 層 (2,000 個頁面): 這些高流量列表在構建時預生成。它們負責大部分有機搜尋流量。它們總是準備好的。
第 2 層 (35,000 個頁面): 首次請求時生成,快取一小時。這些列表有穩定的流量,所以快取過期後的第一個訪問者會得到服務器渲染但快速的頁面。其他所有人都獲得快取版本。
第 3 層 (100,000 個頁面): 首次請求時生成,快取 24 小時。這些列表幾乎沒有流量,所以沒有必要浪費資源。
按需再驗證以進行實時更新
大多數情況由定時再驗證覆蓋,但那家剛更新營業時間的餐廳呢?好吧,我們使用路由處理器推出了 Next.js 的按需再驗證:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } 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: 'Invalid secret' }, { status: 401 });
}
const { slug, type } = await request.json();
if (type === 'listing') {
revalidatePath(`/listing/${slug}`);
revalidateTag(`listing-${slug}`);
} else if (type === 'category') {
revalidateTag(`category-${slug}`);
}
return NextResponse.json({ revalidated: true, now: Date.now() });
}
通過我們的管理面板和 webhook 與此端點對話,任何列表更改器都會在下一次請求時獲得一個新頁面。快速,不是嗎?
處理 137K 個頁面而不會爆炸構建時間
構建時間確實讓我們害怕!以下是我們發現的內容:
| 策略 | 構建時間 | 首次請求延遲 | 快取命中延遲 |
|---|---|---|---|
| 完整 SSG (所有 137K 個頁面) | ~52 分鐘 | ~40ms | ~40ms |
| ISR (2K 預構建) | ~3.5 分鐘 | ~180ms (冷) | ~40ms |
| 完整 SSR (無快取) | ~45 秒 | ~250ms | 不適用 |
| 我們的混合方法 | ~3.5 分鐘 | ~150ms (冷) | ~35ms |
我們的 ISR 方法將構建時間從令人痛苦的一小時縮短到不到 4 分鐘。這是在害怕部署和喝咖啡等待它們運行之間的區別。
`dynamicParams` 設置
這裡有一個至關重要的竅門:保持 dynamicParams = true 以允許 ISR 在 generateStaticParams 之外生成頁面。這聽起來很明顯,但你會對這被忽視的頻率感到驚訝。
export const dynamicParams = true; // 允許按需生成
平行路由片段
對於具有類別和位置的頁面,我們利用平行路由片段,以便篩選器和列表網格可以獨立加載:
// app/directory/[category]/layout.tsx
export default function CategoryLayout({
children,
filters,
}: {
children: React.ReactNode;
filters: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[280px_1fr] gap-6">
<aside>{filters}</aside>
<main>{children}</main>
</div>
);
}
這意味著你的篩選器可以自己被快取。更改篩選器,只有列表網格重新渲染。快速!
數據庫和搜尋層
Neon 上的 PostgreSQL
我們選擇了 Neon,因為它具有無伺服器的優點,如縮放和預覽分支。這種讓我們生活更輕鬆的東西。
我們的列表表很直接,但非常依賴索引:
CREATE INDEX idx_listings_category ON listings(category_id);
CREATE INDEX idx_listings_location ON listings USING GIST(location);
CREATE INDEX idx_listings_rating ON listings(avg_rating DESC);
CREATE INDEX idx_listings_slug ON listings(slug);
CREATE INDEX idx_listings_status_tier ON listings(status, tier);
為什麼在位置上使用 GiST 索引?這都是關於那些精確的地理空間查詢。「我附近的咖啡館」不只是廢話;這是一個真實的計算。
用於搜尋的 Meilisearch
如果你的列表像我們一樣膨脹,PostgreSQL 的文本搜尋就不行了,這就是 Meilisearch 出現的地方。它在價格上勝過 Algolia(30 美元/月 vs 200 美元以上)和它令人印象深刻的容錯能力。
// lib/search.ts
import { MeiliSearch } from 'meilisearch';
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST!,
apiKey: process.env.MEILISEARCH_API_KEY!,
});
export async function searchListings(query: string, filters: FilterParams) {
const index = client.index('listings');
return index.search(query, {
filter: buildFilterString(filters),
facets: ['category', 'city', 'priceRange', 'rating'],
limit: 24,
offset: filters.page * 24,
attributesToHighlight: ['name', 'description'],
});
}
每五分鐘,列表與一個作業同步。我們每週做一次完整重新索引以防萬一。更安全,對吧?
規模上的 SEO:網站地圖、結構化數據和爬蟲預算
對於具有 137,000 個頁面的平台,SEO 不只是好處;這是生死攸關的。以下是我們如何做到的:
動態網站地圖
你不能將 137,000 個 URL 放在一個網站地圖文件中。根據規範,限制是 50,000 個 URL。那麼,我們做什麼呢?我們生成一個網站地圖索引,指向分段的片段:
// app/sitemap.ts
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// 這生成網站地圖索引
const totalListings = await db.listing.count({ where: { status: 'active' } });
const sitemapCount = Math.ceil(totalListings / 10000);
const sitemaps = [];
for (let i = 0; i < sitemapCount; i++) {
sitemaps.push({
url: `${process.env.NEXT_PUBLIC_URL}/sitemap/${i}.xml`,
lastModified: new Date(),
});
}
return sitemaps;
}
分段的網站地圖各有 10,000 個列表,配有時間戳。Google 每天爬取約 8,000-12,000 個頁面。
結構化數據
每個列表頁面都包含 LocalBusiness 架構標記:
function generateStructuredData(listing: Listing) {
return {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: listing.name,
description: listing.description,
address: {
'@type': 'PostalAddress',
streetAddress: listing.address,
addressLocality: listing.city,
addressRegion: listing.state,
postalCode: listing.zip,
},
aggregateRating: listing.reviewCount > 0 ? {
'@type': 'AggregateRating',
ratingValue: listing.avgRating,
reviewCount: listing.reviewCount,
} : undefined,
geo: {
'@type': 'GeoCoordinates',
latitude: listing.lat,
longitude: listing.lng,
},
};
}
這種結構化數據增強了我們的排名,Google 對此類精確信息給予很大偏好。
性能基準
來自我們的實時網站的真實指標,截至 2025 年初:
| 指標 | 值 | 目標 |
|---|---|---|
| 最大內容繪製 (LCP) | 1.1s (p75) | < 2.5s |
| 首次輸入延遲 (FID) | 12ms (p75) | < 100ms |
| 累積佈局偏移 (CLS) | 0.02 (p75) | < 0.1 |
| 首位元組時間 (TTFB) | 85ms (快取) / 190ms (冷 ISR) | < 200ms |
| Lighthouse 性能分數 | 94-98 | > 90 |
| 構建時間 | 3 分 22 秒 | < 5 分 |
| 快取命中率 | 94.7% | > 90% |
那個高快取命中率?是的,我們 94.7% 的頁面直接來自 Vercel 的邊緣 CDN--不需要額外計算。這對速度和成本都是雙贏。
Vercel 上的成本分解
讓我們談談美元和美分。誰不喜歡好交易呢?
| 服務 | 月成本 (2025) | 說明 |
|---|---|---|
| Vercel Pro | $20/席 | 用於專業級功能和限制 |
| Vercel 帶寬 | ~$55 | ~600GB/月,配有 ISR 快取 |
| Vercel 無伺服器功能 | ~$40 | 用於 ISR 工作 + API 內容 |
| Neon PostgreSQL | $19 (Scale 方案) | 10GB 存儲、可擴展計算 |
| Meilisearch Cloud | $30 | 500K 文檔、專用實例 |
| Upstash Redis | $10 | 平均 10K 命令/天 |
| Cloudinary | $25 | 圖像存儲和轉換 |
| 總計 | ~$199/月 | 137K 個頁面,~200K 月訪問量 |
不到 200 美元/月來運行一個擁有 137,000 個頁面的怪獸。相比傳統伺服器設置?你會在 VM、託管數據庫、CDN 和一個全職 DevOps 來照顧它上流血錢。
如果你在這個規模上遊戲並想聊天,聯繫我們或瞥一眼我們的定價。
我們犯的錯誤和我們會改變什麼
錯誤 1:沒有從第一天開始設置按需再驗證
我們最初僅依賴定時再驗證。我告訴你,大錯誤。列表所有者會修改他們的信息並立即檢查。看到舊數據?不是一個信心助推器。再驗證需要是 MVP。
錯誤 2:低估網站地圖複雜性
我們對網站地圖的第一次嘗試將所有內容塞進一個無伺服器功能。排隊超時。Vercel 在超時前給你 10 秒(Pro 上 60 秒)。我們學到了。分段那些東西。
錯誤 3:圖像優化成本
最初,Vercel 處理所有列表照片優化。大量圖像意味著瘋狂的成本。我們將該職責與 Cloudinary 分開,將 Vercel 的魔法保留給 UI 必需品。
錯誤 4:沒有足夠積極地使用 React Server Components
某些初始頁面包含太多 'use client' 命令。結果?太多 JavaScript 被運送。重新關注 Server Components 使我們的 JavaScript 捆綁包輕如羽毛(62% 削減!)。
我們會做什麼不同的事
下一次,我們絕對會從一開始就將 Next.js 與 Payload CMS 之類的東西配對,而不是從頭開始破解管理面板。那會節省多少時間啊!
我們也會密切考慮 Vercel 的最新 unstable_cache(或現在的 cache)用於超越標準 ISR 快取的查詢結果。
常見問題
Next.js ISR 真的能處理數十萬個頁面嗎?
絕對地。我們已經以身作則。使用 generateStaticParams 預生成你排名前的流量頁面(通常 1-5%),讓 ISR 處理其餘部分。Vercel 的邊緣從那裡接管,確保全球快速加載時間。
在 Vercel 上運行大型目錄網站要花多少錢?
對於我們,這大約是 $199/月,用於 137K 個列表,月訪問量 200,000。成本肯定會有所不同,但達到那個甜蜜的快取區間,ISR 可以為你節省大筆資金。
對於目錄網站,ISR 和 SSR 有什麼區別?
ISR 在再驗證間隔內生成一次頁面並快取它們,而 SSR 在每次請求時從頭生成頁面。對於列表在每一分鐘都不變化的情況,ISR 更有效。
你如何在靜態生成的目錄上處理搜尋?
搜尋交互直接進入 Meilisearch,通過 API 調用覆蓋。搜尋結果在客戶端渲染,而列表頁面由 ISR 支持。這是靜態和動態的最佳組合。
我應該在目錄網站的 ISR 上使用什麼再驗證間隔?
取決於變化頻率。我們使用分層方法:優質 10 分鐘、標準 1 小時、安靜列表 24 小時。灑上按需再驗證以進行即時變化。
你如何為 137,000 個頁面生成網站地圖而不超時?
分段是你的朋友。將它們切成 10,000 的塊。通過網站地圖索引路由它們。每個塊應該舒適地保持在超時限制內。
Next.js 是構建目錄平台的最佳框架嗎?
是的,對於重度使用者--特別是帶 ISR 的。對於超級簡單、很少更改的列表?Astro 可以是一個輕量級選項。我們已經製作了兩者;選擇取決於你的工作負載和需求。
你如何防止陳舊數據在 ISR 中傷害用戶體驗?
混合定時和按需再驗證有幫助。將其與客戶端 SWR 或 React Query 配對以獲得超新鮮數據。ISR 提供你的外殼,而實時在選擇性上閃耀。