使用 Next.js、Supabase 和 Vercel ISR 构建 137K 列表全球目录
去年,我们推出了一个拥有 137,000 个列表的全球目录。不是原型。不是"我们稍后会优化"的 MVP。一个生产系统,服务数百万页面浏览量,排名数千个长尾关键词,按需重新生成页面而不会出现问题。这是我们如何构建它的故事——以及使其成为可能的架构决策。
技术栈:Next.js 14(App Router)、Supabase(PostgreSQL + Edge Functions)、Vercel(托管 + ISR)以及大量实用主义。我们犯了错误。我们碰到了墙。我们重写了我们认为已完成的东西。但最终的架构处理 137,000+ 个动态页面,全球 TTFB 低于 200ms,我们的 Supabase 账单保持在每月 100 美元以下。
如果你正在构建类似的东西——市场、目录、列表平台——这是我希望在我们开始时就存在的文章。
目录
- 为什么选择这个技术栈
- 数据层:大规模 Supabase
- 页面生成策略:ISR、SSG 和 137K 问题
- URL 架构和大规模 SEO
- 搜索和筛选:困难的部分
- 性能预算和边缘缓存
- 生产环境中的监控和可观测性
- 成本分解:这实际花费多少
- 我们会做得不同的地方
- 常见问题

为什么选择这个技术栈
在确定 Next.js + Supabase + Vercel 之前,我们评估了很多选项。核心要求是:
- 137,000+ 个唯一页面可供搜索引擎爬取和索引
- 次秒级页面加载全球范围内(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,你会获得无服务器函数调用的突发——每个都需要一个数据库连接。没有池,你会在几分钟内耗尽连接。
我们为所有无服务器上下文使用池化连接字符串(port 6543),仅为迁移和管理任务使用直接连接(port 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!, // 仅服务器端
{
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() {
// 只预生成按流量排名前列的列表
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 // 每小时重新验证一次
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 webhook 触发一个 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(`/`) // 也重新验证主页
}
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 周内索引了提交 URL 的 98%。
结构化数据
每个列表页面都包含 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,
}
搜索和筛选:困难的部分
搜索总是困难的部分。总是。
第 1 阶段: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)
第 2 阶段:当 Postgres 还不够时
在大约 80,000 个列表时,复杂的分面搜索(分类 + 位置 + 文本 + 排序)开始命中 300-500ms。对于大多数应用来说是可接受的,但我们的用户期望即时结果。
我们添加了 Typesense 作为搜索层。不是 Algolia(对我们的规模来说太贵了——我们每月会支付 $500+)。不是 Meilisearch(很好,但 Typesense 的地理搜索更适合我们的用例)。
Typesense 运行在单个 $48/月的 Hetzner 实例上。通过每晚完整重新索引 + 实时 webhook 更新从 Supabase 同步。搜索查询现在平均 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:核心 Web Vitals,真实用户监控
- Sentry:错误跟踪(我们每天捕获约 50 个错误,大多数来自发送垃圾的机器人)
- Supabase Dashboard:数据库性能、查询分析
- Checkly:合成监控,关键路径上 5 分钟间隔
- Google Search Console:索引覆盖范围、爬取统计
我们设置的最有价值的监控是每日 Supabase 查询,计算已索引页面与总活跃列表数。如果比率下降到 95% 以下,我们会收到警报。这在部署坏更改后的 24 小时内捕获了网站地图回归。
成本分解:这实际花费多少
人们总是问关于成本。以下是 2025 年第 1 季度的实际月度支出:
| 服务 | 计划 | 月度成本 |
|---|---|---|
| Vercel | Pro | $20 |
| Vercel 带宽(超出部分) | 按使用付费 | ~$35 |
| Supabase | Pro | $25 |
| Supabase 数据库(计算) | 小实例 | $48 |
| Typesense (Hetzner) | CX31 | $48 |
| Checkly | 初级版 | $7 |
| Sentry | 团队版 | $26 |
| 域名 + DNS (Cloudflare) | 免费层 | $0 |
| 总计 | ~$209/月 |
服务 137,000 个页面,每月数百万页面浏览量,成本约 $200/月。尝试用运行 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,以及一个 Supabase 维护窗口导致 15 分钟的降级搜索。Vercel 的边缘缓存是坚如磐石的。
我应该为大型目录使用 Algolia 还是 Typesense? 这取决于你的预算。Algolia 是业界标准,具有最佳的开发者体验,但超过 100K 条记录会变得昂贵——期望 $500-1000+/月。Typesense 在自托管时以成本的一小部分提供 90% 的功能。我们选择了 Typesense,并且没有后悔。
你如何让 137,000 个列表保持最新? 我们使用多种方法的组合:当单个列表更改时由 Supabase webhook 触发的按需重新验证、基于时间的 ISR 重新验证(每小时)作为安全网,以及检查陈旧数据并触发批量重新验证的每晚批处理作业。列表所有者也可以通过他们的仪表板手动请求页面刷新。
这个架构可以与无头 CMS 一起工作而不是 Supabase 吗? 可以,但有权衡。无头 CMS 设置如 Sanity 或 Contentful 对于内容管理方面效果很好,但你可能仍需要一个数据库用于搜索和复杂查询。我们构建了目录项目,其中编辑内容存在于无头 CMS 中,列表数据存在于 Postgres 中——这是一个有效的混合方法。