如何在 Next.js ISR 上服务 137K 个列表而不爆炸 Vercel 的预算
你的部署在晚上 11 点开始。你看着 Vercel 的构建日志滚过 10,000 个静态路径,然后是 50,000 个,然后在接近 89,000 时卡住。六小时后,构建超时。你的 137,000 个列表目录无法发货,因为你试图在构建时预渲染所有内容——这个错误花了我们 11 天和一个非常尴尬的客户电话。我们最终发货了一个生产系统,服务数百万次页面浏览,为数千个长尾关键词排名,以每月 $209 的价格按需重新生成页面。使其成为可能的架构需要打破我们预渲染所有内容的本能,重新思考 Supabase 查询如何在 ISR 下扩展,以及一个将响应时间降低 340ms 的 Vercel 配置更改。这是真正有效的方法。
堆栈:Next.js 14(App Router)、Supabase(PostgreSQL + Edge Functions)、Vercel(托管 + ISR)和大量的实用主义。我们犯了错误。我们撞了墙。我们重写了我们认为已完成的东西。但最终架构以全球 sub-200ms TTFB 处理 137,000+ 动态页面,我们的 Supabase 账单保持在 $100/月以下。
如果你正在构建类似的东西——一个市场、一个目录、一个列表平台——这是我们开始时希望存在的文章。
目录
- 为什么选择这个堆栈
- 数据层:规模化的 Supabase
- 页面生成策略:ISR、SSG 和 137K 问题
- URL 架构和大规模 SEO
- 搜索和过滤:困难的部分
- 性能预算和边缘缓存
- 生产环境中的监控和可观测性
- 成本明细:这实际花费多少
- 我们会做什么不同的事情
- 常见问题

为什么选择这个堆栈
在选择 Next.js + Supabase + Vercel 之前,我们评估了很多选项。核心要求是:
- 137,000+ 个独特页面,搜索引擎可以爬取和索引
- 全球 sub-second 页面加载(用户在 40+ 个国家)
- 动态数据——列表每天更新,有些每小时更新
- 全文搜索和分面过滤
- 预算意识——这不是风投资助的疯狂项目
我们考虑过 Astro(非常适合静态网站,但我们需要更多动态交互——尽管我们的 Astro 开发团队已经用它交付了出色的目录项目)。我们查看了 WordPress + WPEngine。我们短暂考虑过使用 Algolia 的纯 SPA。
Next.js 之所以赢得青睐,是因为一个杀手锏功能:增量静态重新生成。ISR 意味着我们不必在静态性能和动态内容之间做出选择。我们可以两者兼得。
Supabase 之所以战胜 PlanetScale 和 Neon,是因为它的完整套件——认证、存储、边缘函数和一个真正优秀的带有行级安全的 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,你会得到无服务器函数调用的突发——每个都需要一个数据库连接。没有池,你会在几分钟内耗尽连接。
我们在所有无服务器上下文中使用池连接字符串(端口 6543),仅在迁移和管理任务中使用直接连接(端口 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 分钟的构建限制。即使是 Pro 层也上限 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 webhooks 触发 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(生产) |
|---|---|---|
| 构建时间 | 4h 22m | 7m 40s |
| 部署时的页面 | 137,000 | 5,000 |
| 首次访问(未缓存) | N/A | ~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 // 保持在 50K 限制之下
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,
}
搜索和过滤:困难的部分
搜索总是困难的部分。总是。
第一阶段: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)
第二阶段:当 Postgres 不够时
在大约 80,000 个列表时,复杂分面搜索(类别 + 位置 + 文本 + 排序)开始达到 300-500ms。对大多数应用来说是可以接受的,但我们的用户期望即时结果。
我们添加了 Typesense 作为搜索层。不是 Algolia(在我们的规模太昂贵——我们会支付 $500+/月)。不是 Meilisearch(很好,但 Typesense 的地理搜索对我们的用例更好)。
Typesense 运行在单个 $48/月 Hetzner 实例上。从 Supabase 通过每晚完整重新索引 + 实时 webhook 更新进行同步。搜索查询现在平均 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 张图像。那可能是超过一百万张图像。我们使用 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 小时内发现了站点地图回归。
成本明细:这实际花费多少
人们总是问成本。这是截至 2026 年 Q1 的真实月度支出:
| 服务 | 计划 | 每月成本 |
|---|---|---|
| Vercel | Pro | $20 |
| Vercel 带宽(超额) | 按使用付费 | ~$35 |
| Supabase | Pro | $25 |
| Supabase 数据库(计算) | Small 实例 | $48 |
| Typesense(Hetzner) | CX31 | $48 |
| Checkly | 初学者 | $7 |
| Sentry | 团队 | $26 |
| 域名 + DNS(Cloudflare) | 免费层 | $0 |
| 总计 | ~$209/月 |
以大约 $200/月的价格服务 137,000 个页面,拥有数百万月度页面浏览。尝试用传统服务器设置运行 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 的坏部署,以及一个导致 15 分钟搜索降级的 Supabase 维护窗口。Vercel 的边缘缓存是坚如磐石的。
我应该为大型目录使用 Algolia 还是 Typesense? 这取决于你的预算。Algolia 是业界标准,具有最佳开发者体验,但超过 100K 条记录会变得昂贵——期望 $500-1000+/月。Typesense 在自托管时以一小部分成本交付 90% 的功能。我们选择了 Typesense,没有后悔过。
你如何保持 137,000 个列表最新? 我们使用多种方法的组合:当个别列表更改时由 Supabase webhook 触发的按需重新验证,作为安全网的基于时间的 ISR 重新验证(每小时),以及一个每晚批处理作业,检查陈旧数据并触发批量重新验证。列表所有者也可以通过他们的仪表板手动请求页面刷新。
这个架构可以使用无头 CMS 而不是 Supabase 吗? 可以,但有权衡。一个无头 CMS 设置,如 Sanity 或 Contentful,在内容管理方面工作很好,但你可能仍然需要一个数据库用于搜索和复杂查询。我们已经构建了目录项目,其中编辑内容在无头 CMS 中,列表数据在 Postgres 中——这是一个有效的混合方法。