我們如何使用 Next.js 和 Vercel ISR 建立了一個 137K 列表目錄平台

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

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR

目錄

為什麼目錄平台比看起來要難

目錄網站可能看起來很直接。你可能認為一個列表頁面、一個詳細頁面、撒一些篩選器,瞧!完成了。但一旦你超過幾千個列表,一切都陷入了複雜性。

這裡真正發生的是:

  • 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 是我們的大賣點。

How We Built a 137K Listing Directory Platform with Next.js and Vercel ISR - architecture

在規模上實際有效的 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 提供你的外殼,而實時在選擇性上閃耀。