Next.js i18n at Scale: 30言語、91K ページ、Vercel ISR
去年,我们发布了一个 Next.js 项目,每次想起它我都有点紧张。30 种语言。超过 91,000 个静态生成页面。Vercel ISR 保持一切最新。这种项目中,一个错误的架构决策意味着你会面临 4 小时的构建时间、每月 800 美元的托管账单,或者——最坏的情况——一个在韩语中根本无法工作的网站。
这是关于我们如何做对的故事(以及最初我们做错的部分)。如果你正在构建大规模国际化 Next.js 应用程序,并想知道 ISR 是否真的能在生产中处理这种规模,这篇文章就是为你而写的。

目录
- 问题:为什么 91K 页面是另一种情况
- 我们早期做出的架构决策
- 为 30 个区域设置 Next.js i18n
- 真正有效的 ISR 策略
- 内容管道和无头 CMS 集成
- 性能结果和核心网页性能指标
- Vercel 成本分解
- 我们犯的错误以及如何修复
- 何时使用此堆栈(以及何时不使用)
- 常见问题
问题:为什么 91K 页面是另一种情况
让我设置一下背景。客户是一家扩展到 30 个市场的企业电子商务品牌。每个市场需要:
- 本地化产品页面(约 2,800 个产品 × 30 个区域 = 84,000 页)
- 分类页面(约 120 个分类 × 30 个区域 = 3,600 页)
- CMS 驱动的营销页面(约 120 × 30 = 3,600 页)
- 总计:大约 91,200 个独特 URL
使用普通的 getStaticPaths 和完整静态生成,初始构建将花费 3 到 5 小时之间的时间。这不是打字错误。我们对早期原型进行了基准测试,并看着数字不断上升。每次部署都意味着数小时的停机风险,内容团队希望每天发布多次更新。
SSR 也不是一个选项。客户的流量模式显示销售事件期间会出现大规模峰值——我们说的是 50,000 并发用户。在这种负载下服务器渲染 91K 个可能的页面变体需要大量计算,并引入会杀死转化率的延迟。
ISR 是答案。但这种规模的 ISR 有其自身的一系列挑战,Next.js 文档并没有真正为你准备好。
我们早期做出的架构决策
在编写单行 i18n 代码之前,我们做出了三个架构决策,这些决策后来为我们节省了数月的痛苦。
决策 1:子路径路由,而不是域
Next.js 支持两种 i18n 策略:子路径路由(/fr/products/...)和域路由(fr.example.com)。我们选择了子路径路由。原因如下:
| 因素 | 子路径路由 | 域路由 |
|---|---|---|
| DNS/SSL 复杂性 | 单一域 | 30 个域/子域要管理 |
| Vercel 部署 | 一个项目 | 一个项目(但域配置开销) |
| SEO 链接权重 | 在一个域上整合 | 分散在多个域 |
| CDN 缓存效率 | 更好(共享边缘缓存) | 分散 |
| 分析设置 | 更简单 | 30 个属性或复杂过滤 |
对于大多数 50 个区域以下的项目,子路径路由是正确选择。域路由在你需要特定国家的 TLD 出于法律原因,或当你的市场有根本不同的内容架构时才有意义。
决策 2:next-intl 而不是 next-i18next
我们广泛评估了两个库。在 2025 年,next-intl(v4.x)已成为 App Router 项目的更强选择,尽管我们在这个构建中使用的是 Pages Router。即使在 Pages Router 上,next-intl 也给了我们:
- 更好的 TypeScript 支持,带有类型安全的消息键
- 更小的客户端捆绑(约 2.1KB gzipped,相比 next-i18next 的 ~5KB)
- 对 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 页在首次请求时生成,并在边缘缓存。

为 30 个区域设置 Next.js i18n
Next.js 内置的 i18n 配置在 next.config.js 中处理区域检测和路由。以下是我们配置的样子(简化版):
// 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 限制。
Stale-While-Revalidate 模式
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。工作流程:
- 开发者将新的英文字符串添加到
messages/en/*.json - Crowdin 同步并通知翻译者
- 翻译作为 PR 回来
- CI 验证 JSON 结构和完整性
- 缺失的翻译回退到英语
回退策略很关键。你永远不想在生产页面上看到像 product.add_to_cart 这样的翻译键。我们的回退链是:请求的区域 → 语言族(例如 pt-BR → pt)→ 英语。
性能结果和核心网页性能指标
发布后,以下是我们在所有 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 标签是错误的
这是一个容易出错的。Google 需要 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 文件将不起作用(Google 的限制是每个站点地图 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' 设置防止爬虫看到不完整页面。Google 已确认 ISR/缓存页面的处理方式与传统静态 HTML 相同。
如何在不重新部署的情况下处理翻译更新? 对于 CMS 管理的内容(产品描述、营销文案),翻译通过 ISR 重新验证自动更新——通过计时器或通过按需重新验证 webhook。对于 UI 字符串翻译(按钮标签、表单验证消息),这些在构建时绑定,所以它们需要重新部署。我们有意将这些分开:内容变化永远不应该需要部署,但 UI 字符串变化会通过代码审查。
对于 Vercel 上的多语言网站,ISR 和 SSR 的成本差异是多少? SSR 在每次请求时执行无服务器函数。在 2M 页面浏览量/月的情况下,这是 2M 函数调用,在免费层后大约 $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 中断立即成为面向用户的中断。