去年,我们发布了一个 Next.js 项目,每当想起它时,我仍然有些紧张。30 种语言。超过 91,000 个静态生成页面。Vercel ISR 保持一切最新。这种项目中,一个错误的架构决策意味着你要面对 4 小时的构建、800 美元/月的托管费用,或者——最坏的情况——一个在韩文中根本无法工作的网站。

这是一个关于我们如何做对了(以及最初做错的部分)的故事。如果你正在构建大规模国际化 Next.js 应用,并想知道 ISR 是否真的能在生产环境中处理这种规模,这篇文章就是为你而写的。

Next.js i18n at Scale: 30 Languages, 91K Pages, Vercel ISR

目录

问题所在:为什么 91K 页面是个不同的挑战

让我设置一下场景。客户是一个扩展到 30 个市场的企业电商品牌。每个市场都需要:

  • 本地化产品页面(~2,800 个产品 × 30 种语言 = 84,000 页)
  • 分类页面(~120 个分类 × 30 种语言 = 3,600 页)
  • CMS 驱动的营销页面(~120 × 30 = 3,600 页)
  • 总计:大约 91,200 个唯一 URL

使用普通的 getStaticPaths 和完整静态生成,初始构建将需要 3 到 5 小时之间的时间。这不是打字错误。我们对早期原型进行了基准测试,看着这个数字攀升。每次部署都意味着数小时的停机风险,而内容团队想每天多次发布更新。

SSR 也不是一个选项。客户的流量模式显示销售活动期间大幅峰值——我们谈论的是 50K 并发用户。在这种负载下服务器渲染 91K 个可能的页面变体需要认真的计算资源,并会引入杀死转化率的延迟。

ISR 是答案。但这种规模的 ISR 有其自己的一系列挑战,Next.js 文档并没有真正为你做好准备。

我们早期做出的架构决策

在写一行 i18n 代码之前,我们做出了三个架构决策,后来为我们避免了数月的痛苦。

决策 1:子路径路由,而非域名

Next.js 支持两种 i18n 策略:子路径路由(/fr/products/...)和域名路由(fr.example.com)。我们选择了子路径路由。原因如下:

因素 子路径路由 域名路由
DNS/SSL 复杂性 单一域名 30 个域名/子域名需要管理
Vercel 部署 一个项目 一个项目(但有域名配置开销)
SEO 链接权重 集中在一个域名 分散在多个域名
CDN 缓存效率 更好(共享边缘缓存) 分散
分析设置 更简单 30 个属性或复杂过滤

对于大多数少于 50 种语言的项目,子路径路由是正确之举。域名路由在你出于法律原因需要特定国家的顶级域名,或者你的市场具有完全不同的内容架构时才有意义。

决策 2:next-intl 而非 next-i18next

我们广泛评估了这两个库。到 2025 年,next-intl(v4.x)已成为 App Router 项目的更强选择,尽管我们在这个构建中使用的是 Pages Router。即使在 Pages Router 上,next-intl 也为我们提供了:

  • 使用类型安全消息键的更好 TypeScript 支持
  • 更小的客户端包(gzip 压缩约 2.1KB vs ~5KB for next-i18next)
  • 对 ICU 消息格式的原生支持(复数、性别、数字格式化)
  • ISR 页面更简单的配置

决策 3:部分静态生成 + ISR

这是大事件。我们没有尝试在构建时静态生成所有 91K 页面,而是只预生成了最高流量的页面(约 8,000 个),让 ISR 处理其余的按需页面。

// pages/[locale]/products/[slug].tsx
export async function getStaticPaths() {
  // 仅预生成前 100 个产品 × 前 5 种语言
  const topProducts = await getTopProducts(100);
  const primaryLocales = ['en', 'de', 'fr', 'es', 'ja'];
  
  const paths = topProducts.flatMap(product =>
    primaryLocales.map(locale => ({
      params: { slug: product.slug, locale },
    }))
  );

  return {
    paths,
    fallback: 'blocking', // ISR 处理其他一切
  };
}

这将我们的构建时间从 3+ 小时降低到大约 12 分钟。其余 83,000 个页面在首次请求时生成并在边缘缓存。

Next.js i18n at Scale: 30 Languages, 91K Pages, Vercel ISR - architecture

为 30 种语言设置 Next.js i18n

next.config.js 中的 Next.js 内置 i18n 配置处理区域设置检测和路由。这是我们的配置样子(缩写):

// next.config.js
const nextConfig = {
  i18n: {
    locales: [
      'en', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'pl', 'cs', 'da',
      'sv', 'fi', 'nb', 'hu', 'ro', 'bg', 'hr', 'sk', 'sl', 'el',
      'tr', 'ja', 'ko', 'zh-CN', 'zh-TW', 'th', 'vi', 'id', 'ms', 'ar'
    ],
    defaultLocale: 'en',
    localeDetection: false, // 我们自己处理这个
  },
};

这里需要注意几点。我们禁用了 localeDetection,因为内置检测(基于 Accept-Language 标头)导致 ISR 缓存出现问题。当 Vercel 的 CDN 缓存一个页面时,区域设置需要从 URL 确定,而不是从标头。让 Next.js 根据浏览器语言自动重定向意味着缓存未命中和不一致的行为。

相反,我们构建了一个自定义的区域设置检测中间件,仅在根路径上运行:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const SUPPORTED_LOCALES = ['en', 'de', 'fr', /* ... */];
const DEFAULT_LOCALE = 'en';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // 仅在根路径重定向
  if (pathname === '/') {
    const acceptLanguage = request.headers.get('accept-language') || '';
    const preferred = acceptLanguage.split(',')[0]?.split('-')[0] || DEFAULT_LOCALE;
    const locale = SUPPORTED_LOCALES.includes(preferred) ? preferred : DEFAULT_LOCALE;
    
    return NextResponse.redirect(new URL(`/${locale}`, request.url));
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: ['/'],
};

翻译文件结构

使用 30 种语言,翻译文件管理成为真正的关切。我们按命名空间组织翻译:

messages/
├── en/
│   ├── common.json
│   ├── product.json
│   ├── checkout.json
│   └── marketing.json
├── de/
│   ├── common.json
│   ├── product.json
│   └── ...
└── ar/
    └── ...

所有语言中的总翻译负载约为 4.2MB。但由于我们使用 getStaticProps 按页面加载翻译,每个单独的页面仅为其区域设置和命名空间加载 15-40KB 的翻译数据。这是关键——你不想将所有 30 种语言发送给客户端。

export async function getStaticProps({ locale }: GetStaticPropsContext) {
  return {
    props: {
      messages: {
        ...(await import(`../../messages/${locale}/common.json`)).default,
        ...(await import(`../../messages/${locale}/product.json`)).default,
      },
    },
    revalidate: 300, // ISR:每 5 分钟重新验证一次
  };
}

RTL 支持阿拉伯语

阿拉伯语是我们集合中唯一的 RTL 语言。我们用一个简单的布局包装器处理它:

const direction = locale === 'ar' ? 'rtl' : 'ltr';

return (
  <html lang={locale} dir={direction}>
    <body className={direction === 'rtl' ? 'font-arabic' : 'font-sans'}>
      {children}
    </body>
  </html>
);

加上 Tailwind 的 rtl: 变体用于间距和布局调整。这出乎意料地工作良好——也许 5% 的 CSS 需要 RTL 特定的覆盖。

真正有效的 ISR 策略

ISR(增量静态再生)是这个故事的英雄,但在规模上很好地使用它需要理解 Vercel 的基础设施实际上是如何工作的。

重新验证时间

我们根据内容类型使用了不同的重新验证周期:

页面类型 重新验证周期 原因
产品页面 300s(5 分钟) 价格/库存经常变化
分类页面 900s(15 分钟) 产品列表更新不那么频繁
营销/CMS 页面 3600s(1 小时) 内容变化是计划好的
每种语言的主页 600s(10 分钟) 新鲜度和缓存的平衡

按需重新验证

对于关键更新(价格变化、库存缺货),我们从无头 CMS 设置了通过 webhook 的按需重新验证:

// pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { secret, slug, locales } = req.body;
  
  if (secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ message: '无效的密钥' });
  }

  try {
    const targetLocales = locales || ['en']; // 如果未指定,默认为英语
    
    const revalidations = targetLocales.map((locale: string) =>
      res.revalidate(`/${locale}/products/${slug}`)
    );
    
    await Promise.all(revalidations);
    
    return res.json({ revalidated: true, paths: targetLocales.length });
  } catch (err) {
    return res.status(500).json({ message: '重新验证错误' });
  }
}

一个陷阱:当你重新验证一个存在于 30 种语言中的产品时,你正在进行 30 次重新验证调用。对于 100 个产品的批量更新,这是 3,000 次重新验证请求。我们不得不添加速率限制并通过无服务器函数排队这些,以避免达到 Vercel 的 API 限制。

过时再验证模式

ISR 的妙处在于它在后台再生时提供过时的内容。对于这个项目,这意味着用户总是得到快速响应(来自 Vercel 边缘的缓存 HTML),即使数据最多有 5 分钟的陈旧。对于电商网站,这是一个可接受的权衡——购物车和结账流程总是会命中实时 API 以获得实时库存/定价。

内容管道和无头 CMS 集成

内容位于无头 CMS(本例为 Contentful,尽管我们为其他客户使用 Sanity 和 Storyblok 做了类似的设置——有关更多信息,请参阅我们的 无头 CMS 开发服务)。

Contentful 的本地化模型对 30 种语言表现良好。每个条目都有特定于区域设置的字段值,其 API 支持按区域设置查询。但有一个性能考虑:获取一个具有所有 30 种语言数据的产品明显大于获取一种语言。

我们总是在 getStaticProps 中查询单一区域设置:

const product = await contentfulClient.getEntry(productId, {
  locale: mapToContentfulLocale(locale), // 'en-US', 'de-DE', 等
  include: 2, // 解析 2 层关联条目
});

这使 API 响应时间对于具有多个引用的复杂产品条目保持在 200ms 以下。

翻译管理

对于 UI 翻译(按钮、标签、错误消息),我们使用集成到 Git 仓库的 Crowdin。工作流程:

  1. 开发者将新的英语字符串添加到 messages/en/*.json
  2. Crowdin 同步并通知译者
  3. 翻译回来作为 PR
  4. CI 验证 JSON 结构和完整性
  5. 缺失的翻译回退到英语

回退策略是关键。你绝对不想在生产页面上显示翻译键,如 product.add_to_cart。我们的回退链是:请求的区域设置 → 语言族(例如 pt-BRpt)→ 英语。

性能结果和核心网络指标

发布后,这是我们在所有 30 种语言中测量的内容:

指标 目标 实际(P75) 备注
LCP < 2.5s 1.8s ISR 缓存命中
FID < 100ms 45ms 最少客户端 JS
CLS < 0.1 0.03 字体加载策略有帮助
TTFB < 800ms 120ms Vercel 边缘,缓存页面
TTFB(缓存未命中) < 2s 1.4s ISR 在首次请求时生成
构建时间 < 20min 11min 40s 仅预生成 8K 页面

TTFB 数字是这里的明星。缓存页面的 120ms 意味着东京、圣保罗和法兰克福的用户都能从附近的边缘节点获得快速响应。缓存未命中的 1.4s 是 ISR 生成时间——是可接受的,因为它每个页面每个重新验证周期只发生一次。

30 种语言的字体加载

特定于多语言网站的一个性能挑战:字体。你不能为 30 种语言使用单一字体族。我们需要:

  • 拉丁/西里尔文:Inter(大多数欧洲语言)
  • 阿拉伯语:Noto Sans Arabic
  • CJK:Noto Sans JP/KR/SC/TC
  • 泰语:Noto Sans Thai

使用 next/font 通过每种语言的字体加载防止了不必要的字体下载。访问日语网站的用户仅下载 Noto Sans JP,而不是阿拉伯语或泰语字体。

Vercel 成本分解

让我们谈论金钱,因为这是大规模 ISR 变得有趣的地方。这是我们 2025 年每月 Vercel 账单分解:

行项目 月度费用 备注
Vercel Pro 计划 $20/座 × 4 基础团队计划
带宽(8TB/月) ~$320 首 1TB 后 $40/TB
无服务器函数执行 ~$180 ISR 再生 + API 路由
边缘中间件执行 ~$45 区域设置检测
ISR 写入 ~$90 缓存写入操作
总计 ~$715/月

对于在 30 种语言中处理 2M+ 页面浏览量/月的网站,715 美元是非常合理的。替代方案——在专用基础设施上运行 SSR——将花费 2,000-4,000 美元/月来实现等效的性能和可靠性。

一件要注意的事情:如果你触发大规模重新验证,ISR 缓存写入成本可能会激增。我们有一个事件,其中 CMS 批量发布触发了 15,000 个页面的重新验证。该单个事件在额外函数执行中花费了约 40 美元。我们现在以 100ms 的延迟批量重新验证调用。

我们犯的错误以及如何修复

如果我说这从第一天起就顺利进行,那就是在撒谎。以下是最大的错误:

错误 1:在构建时生成所有区域设置

我们的第一种方法尝试在每种语言中预生成每个页面。构建运行了 3 小时 47 分钟。然后它失败了,因为 Vercel 的构建超时(在 Pro 上)是 45 分钟。即使转移到自定义构建服务器后,部署过程也很痛苦。

修复: 使用 fallback: 'blocking' 的部分预生成。只构建最重要的页面,让 ISR 处理长尾。

错误 2:没有正确设置 `fallback`

我们最初使用了 fallback: true 而不是 fallback: 'blocking'。区别很重要:true 在首次请求时提供骨架/加载状态,而 blocking 等待页面生成。使用 true,我们遇到了水合错误,因为我们的产品组件期望在回退渲染期间还不存在的数据。

修复: 切换到 fallback: 'blocking'。未缓存页面的第一个访问者要等待 1-2 秒,但之后的每个人都立即获得缓存版本。

错误 3:SEO Hreflang 标签是错的

这是一个容易搞砸的问题。谷歌需要 hreflang 标签来理解本地化页面之间的关系。我们的初始实现缺少 x-default 标签,在 <link> 标签和 XML 站点地图之间存在不一致。

// 正确的 hreflang 实现
<Head>
  {locales.map(loc => (
    <link
      key={loc}
      rel="alternate"
      hrefLang={loc}
      href={`https://example.com/${loc}${path}`}
    />
  ))}
  <link rel="alternate" hrefLang="x-default" href={`https://example.com/en${path}`} />
</Head>

错误 4:站点地图生成

有 91K 个 URL,单个站点地图 XML 文件无法工作(谷歌的限制是每个站点地图 50,000 个 URL)。我们需要一个包含多个子站点地图的站点地图索引,按区域设置分割:

<!-- sitemap-index.xml -->
<sitemapindex>
  <sitemap><loc>https://example.com/sitemaps/en.xml</loc></sitemap>
  <sitemap><loc>https://example.com/sitemaps/de.xml</loc></sitemap>
  <!-- ... 还有 28 个 -->
</sitemapindex>

我们使用带有自定义配置的 next-sitemap 生成了这些,并在每次构建时重新生成。

何时使用这种技术栈(以及何时不用)

这种架构——Next.js + i18n + Vercel 上的 ISR——很强大,但并不适合所有情况。

在以下情况下使用:

  • 你有 10+ 种语言,数千个页面
  • 内容更新频繁但不是实时的
  • 性能和核心网络指标对 SEO 很重要
  • 你的团队对 React/Next.js 很熟悉

在以下情况下考虑替代方案:

  • 你有少于 5 种语言,少于 1,000 个页面(普通 SSG 可能更简单)
  • 内容是真正实时的(股票交易、实时比分)——使用 SSR 或客户端获取
  • 你在托管上预算紧张——考虑 Astro 用于纯静态多语言网站,成本仅为其一小部分
  • 你的团队很小且不需要 React 的交互性——静态网站生成器加 i18n 可能需要较少的维护

对于考虑这样的项目的团队,我们帮助了多个企业客户设计和构建大规模 Next.js 应用。前两周的架构决策决定了项目是否成功或成为维护噩梦。如果你想讨论你的具体情况,联系我们

常见问题

Next.js i18n 路由如何与 ISR 一起工作? Next.js i18n 路由将区域设置前缀添加到 URL(如 /fr/products/shoes)。与 ISR 结合使用时,每个区域设置 + 页面组合在 Vercel 的边缘独立缓存。所以 /en/products/shoes/fr/products/shoes 是单独的缓存条目,各自有自己的重新验证计时器。getStaticProps 函数在其上下文中接收区域设置,你在那里获取适当的翻译和本地化内容。

Next.js ISR 在 Vercel 上最多可以处理多少页面? Vercel 可以提供的 ISR 页面数量没有硬技术限制。我们成功运行了 91K+ 页面,我听说过具有 500K+ 页面的项目。实际限制是构建时间(对于预生成页面)、重新验证吞吐量和成本。Vercel 的边缘缓存设计用于这种规模——它本质上是一个具有智能无效化的 CDN。

ISR 会影响多语言网站的 SEO 吗? 不会,ISR 页面在从缓存提供时是完全呈现的 HTML,这是搜索引擎爬虫看到的。关键的 SEO 考虑是适当的 hreflang 标签、一个结构良好的站点地图索引(包含每种语言的站点地图),以及确保你的 fallback: 'blocking' 设置防止爬虫看到不完整的页面。谷歌已确认 ISR/缓存页面被视为与传统静态 HTML 相同。

如何在不重新部署的情况下处理翻译更新? 对于 CMS 管理的内容(产品描述、营销文案),翻译通过 ISR 重新验证自动更新——要么按计时器,要么通过按需重新验证 webhook。对于 UI 字符串翻译(按钮标签、表单验证消息),这些在构建时进行捆绑,因此需要重新部署。我们故意将这些分开:内容更改永远不应该需要部署,但 UI 字符串更改要进行代码审查。

ISR 与 Vercel 上多语言网站的 SSR 成本有什么差异? SSR 在每个请求上执行无服务器函数。在 2M 页面浏览量/月的情况下,这是 200 万次函数调用,免费层后大约每百万 $0.40——月费约 800 美元,加上明显更高的带宽,因为缓存较少。我们的 ISR 设置月费约 715 美元,而等效的 SSR 将花费 2,500-3,500 美元/月。

你如何跨 30 种语言处理不同的日期、数字和货币格式? 我们通过 next-intl 的格式化工具使用浏览器的内置 Intl API。这正确处理每种语言的日期格式化(Intl.DateTimeFormat)、数字格式化(Intl.NumberFormat)和货币显示。ICU 消息格式让你直接在翻译字符串中嵌入这些格式化器:"price": "From {amount, number, ::currency/EUR}"。这在 ISR 生成期间在服务器端工作,对于动态值在客户端工作。

我应该为大规模 i18n 使用 App Router 还是 Pages Router? 从 Next.js 15(2025 年中期)开始,App Router 的 i18n 故事已经成熟,next-intl v4 具有出色的 App Router 支持。对于新项目,我会推荐 App Router。它提供更好的流式传输、React 服务器组件(减少客户端 JavaScript)和更细粒度的缓存控制。我们的项目使用了 Pages Router,因为它开始于 2024 年,当时 App Router i18n 不太稳定,但今天的绿地项目应该使用 App Router。

如果 ISR 重新验证失败会怎样?用户会看到错误页面吗? 不会,这是 ISR 最好的功能之一。如果重新验证失败(也许 CMS API 宕机,或 getStaticProps 中有代码错误),Vercel 继续提供上次成功生成的页面版本。用户永远不会看到错误——他们只是看到略微陈旧的内容。失败的重新验证被记录,下一个重新验证尝试将再次尝试。这使 ISR 与 SSR 相比极其富有弹性,其中 API 缺电会立即成为用户面临的缺电。