我们如何使用 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 (管理) | 自定义管理 + Payload CMS | 列表管理、批量操作 |
| CDN/图像 | Vercel 图像优化 + 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 页面):首次请求时生成,缓存 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 提供支持,而实时在选择性地闪耀。