你的部署在晚上 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/月以下。

如果你正在构建类似的东西——一个市场、一个目录、一个列表平台——这是我们开始时希望存在的文章。

目录

使用 Next.js、Supabase & Vercel ISR 构建 137K 列表全球目录

为什么选择这个堆栈

在选择 Next.js + Supabase + Vercel 之前,我们评估了很多选项。核心要求是:

  1. 137,000+ 个独特页面,搜索引擎可以爬取和索引
  2. 全球 sub-second 页面加载(用户在 40+ 个国家)
  3. 动态数据——列表每天更新,有些每小时更新
  4. 全文搜索和分面过滤
  5. 预算意识——这不是风投资助的疯狂项目

我们考虑过 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 方法(真正有效的)

这是发货的策略:

  1. 在构建时:静态生成前 5,000 个页面(按流量)
  2. 在首次请求时:按需生成剩余页面并缓存它们
  3. 重新验证:基于时间(每 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 分钟

使用 Next.js、Supabase & Vercel ISR 构建 137K 列表全球目录 - 架构

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 中——这是一个有效的混合方法。