我们如何使用 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 (管理) 自定义管理 + Payload CMS 列表管理、批量操作
CDN/图像 Vercel 图像优化 + 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 页面):首次请求时生成,缓存 1 小时。这些列表有稳定的流量,所以缓存过期后的第一个访问者会得到服务器渲染但快速的页面。其他所有人都得到缓存版本。

第 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() });
}

通过我们的管理面板和网络钩子与这个端点通信,任何列表变更者在下次请求时都会获得新页面。快速,对吧?

处理 137K 页面而不让构建时间爆炸

构建时间真的让我们害怕!这是我们发现的:

策略 构建时间 首次请求延迟 缓存命中延迟
完整 SSG(所有 137K 页面) ~52 分钟 ~40ms ~40ms
ISR(2K 预构建) ~3.5 分钟 ~180ms(冷启动) ~40ms
完整 SSR(无缓存) ~45 秒 ~250ms N/A
我们的混合方法 ~3.5 分钟 ~150ms(冷启动) ~35ms

我们的 ISR 方法将构建时间从令人痛苦的 1 小时减少到仅仅 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 的用武之地。它在价格($30/月 vs $200+)和令人印象深刻的容错能力上击败了 Algolia。

// 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:Sitemaps、结构化数据和抓取预算

对于拥有 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(规模计划) 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 分工,为 UI 必需品保留 Vercel 的魔力。

错误 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 为您的 shell 提供支持,而实时在选择性地闪耀。