Next.js i18n
为 Next.js 应用提供多语言支持的结构性指导:文件夹布局必须如何,出错时会出现什么问题,以及应该避免什么。
首先选择库
对于 App Router 上的新项目,默认选择 next-intl。它不需要构建插件、宏步骤或每个语言实例的管理;消息是纯 JSON 格式,并根据你的源语言进行类型化。
| 库 | 使用场景 | 成本 |
|---|---|---|
| next-intl (默认) | 新的 App Router 项目,任何团队规模 | 无 —— 纯 JSON,无需代码生成 |
| @lingui/react | 你想要编译时提取和微小的运行时,或者希望跨 React/Vue/Astro 使用同一个 i18n 层 | SWC 或 Babel 宏插件,需要精确锁定版本 |
| next-i18next | 团队已经熟悉 i18next,或者你需要迁移 Pages Router 的代码 | 每次请求都需要实例,getT/useT 分离 |
Next.js 内置的 i18n | 仅限 Pages Router。 绝不用于 App Router | 不适用 |
Next.js 的 i18n: { locales, defaultLocale } 配置仅适用于 Pages Router。在 App Router 中它不生效 —— 那里的语言路由意味着需要一个 [locale] 段加上一个代理。不要让 AI 代理为 App Router 项目"启用"内置的 i18n。
完整的比较,包括打包成本和每个库的权衡:references/library-comparison.md。
标准架构
将静态字典放在 src/ 之外,这样打包和导入路径保持清晰,并将所有语言机制放在一个文件夹中。
messages/ # 纯静态字典 —— 放在 /src 之外
├── en.json # 源文件;其他每种语言都镜像它
├── de.json
└── ja.json
src/
├── i18n/
│ ├── routing.ts # defineRouting: 语言 + 默认语言
│ ├── request.ts # getRequestConfig: 解析语言,加载消息
│ └── navigation.ts # createNavigation: 识别语言的 Link/redirect
├── proxy.ts # 语言检测 + 重定向 (Next 16;之前是 middleware.ts)
└── app/
├── [locale]/ # 包裹整个本地化应用
│ ├── layout.tsx # 验证语言,<html lang>,generateStaticParams
│ └── page.tsx
└── not-found.tsx # 全局 404 放在 [locale] 之外
使这种结构生效的规则:
[locale]是一个根动态段。 所有本地化的内容都位于其下。根级路由(not-found.tsx、error.tsx、api/)放在外部,并且不能无条件调用 i18n 钩子,否则会在运行时崩溃。- 一个源语言。
messages/en.json是主文件。每个其他文件必须完全镜像其结构。 - 所有语言知识放在
src/i18n/中。 组件从@/i18n/navigation导入Link,绝不从next/link导入。添加一种语言只需修改一处。
搭建(新项目)
按此顺序运行。顺序很重要 —— proxy.ts 依赖于 routing.ts,布局依赖于 request.ts。
- 安装
next-intl,用createNextIntlPlugin()包裹next.config.ts。 - 使用
defineRouting创建src/i18n/routing.ts。 - 创建
src/proxy.ts,导出createMiddleware(routing)和默认匹配器。 - 使用
getRequestConfig创建src/i18n/request.ts,解析语言。 - 将
app/**移动到app/[locale]/**下;添加generateStaticParams并验证参数。 - 使用
createNavigation(routing)创建src/i18n/navigation.ts。 - 创建
messages/en.json,并用useTranslations替换硬编码的字符串。 - 设置
<html lang>并添加本地化元数据和 hreflang。
每个步骤的精确文件内容:references/next-intl-setup.md。
陷阱
在编写任何 i18n 代码之前阅读本节。这些是会导致浪费数小时的问题。
setRequestLocale是旧的。 在 Next.js 16.3+ 上,应该在getRequestConfig中使用next/root-params读取语言。大多数指南、教程和 LLM 训练数据仍然教授setRequestLocale(params.locale)。它仍然有效且被记录为向后兼容 —— 但它是旧 API。只在 Next < 16.3 上使用它。无论如何,generateStaticParams仍然需要。- 在 Next 15+ 上
params是一个 Promise。 使用const {locale} = await params。将params.locale视为类型错误,而不是运行时错误。 - 验证语言,否则未知值会渲染。 使用
hasLocale(routing.locales, locale)和notFound()。如果没有,/xx/about会在一个虚假的 URL 上渲染你的英文页面。 - 在 Next 16 上是
proxy.ts,而不是middleware.ts。middleware.ts已弃用。代理仅在 Node.js 上运行 —— 不支持 Edge 运行时。Lingui 和 next-i18next 宣传 Edge 安全的代理;如果你需要 Edge,你面临的是一个真正的权衡,而不是免费的胜利。 - 绝不拼接翻译后的字符串。
"You have " + count + " items"在日语和阿拉伯语中是不合语法的。使用 ICU:"{count, plural, =1 {One item} other {# items}}"。 - 绝不使用
+或消息内的模板字面量进行插值。 使用命名占位符 ——{name}—— 以便词序可以调整。 - 日期和数字会导致水合不匹配。 服务器上的
new Date()和浏览器中的会因时区和时钟而不同。在getRequestConfig中设置timeZone,或者对于相对时间仅作客户端渲染。症状:"Text content does not match server-rendered HTML"。 - 格式化依赖于语言环境。 没有显式语言环境的
toLocaleString()会使用运行时语言环境进行格式化,而不是请求的语言环境。使用useFormatter()/getFormatter()。 getRequestConfig每次请求运行一次,并被 React 缓存。 不要将请求变化的 state(购物车、用户偏好)放在里面。只放语言变化的配置。- 服务器操作(Server Actions)和路由处理程序(Route Handlers)没有根参数。
await locale()仅适用于服务器组件。在操作和处理程序中显式传递语言作为参数。
改造现有应用
将其视为一个有 URL 影响的迁移,而不是配置更改。当你添加 [locale] 前缀时,每个现有 URL 都会改变形状。
按此顺序计划:
- 盘点字符串 —— 运行
scripts/find-untranslated.sh来查找硬编码的 JSX 文本。 - 在编码之前选择路由策略。 始终使用语言前缀(
/en/about、/de/about)是默认的;它没有歧义且 hreflang 清晰。localePrefix: 'as-needed'在裸路径上提供默认语言 —— 更好的 URL,但 SEO 信号较弱。现在做出决定;稍后切换是完全重新迁移。 - 在接触任何组件之前搭建路由。 先让
proxy.ts+[locale]配合仅英文的消息工作。 - 将路由移动到
[locale]/下 —— 这是一个大型的机械性git mv。在一个提交中完成,这样git log --follow保持可用。 - 按命名空间替换字符串,而不是按文件。端到端地翻译一个命名空间,以便在键形状复制之前得到验证。
- 为更改的每个 URL 添加重定向。
/about→/en/about(或 307 到检测到的语言)。如果没有这个,你会丢失所有现有的排名。不可商量。 - 只有在每个语言都有该路由后才添加 hreflang + 本地化元数据,否则你会发出指向 404 的替代链接。
带有重定向和回滚说明的完整迁移序列:references/migration.md。
应该避免什么
- 不要将
messages/放在src/内。 源代码树中的仅服务器字典模糊了哪些内容被发送到客户端。 - 不要在本地化路由中从
next/link导入Link。 它会去掉语言前缀并静默地链接到错误的语言。 - 不要在 href 中手动编写语言前缀(
href={/${locale}/about})。使用@/i18n/navigation中的Link,它可以处理带前缀的、按需的和域路由。 - 不要使用一个扁平的键命名空间。 按功能/页面分组。扁平的键会冲突且难以搜索。
- 不要将构建时的自动翻译作为源文件。 机器输出是草稿;未经审查就提交会使错误永久化且无法追踪。
- 不要只是将一种语言添加到
routing.locales就认为完成了。 没有翻译的messages/xx.json,每个页面都会渲染键或静默回退。 - 不要混合使用路由策略。
as-needed加上指向带前缀 URL 的hreflang是一个自找的 SEO 错误。
参考文献
按需阅读,无需提前阅读:
references/library-comparison.md—— 在 next-intl、Lingui、next-i18next 之间选择;打包成本、迁移触发条件、每个库的设置结构。references/next-intl-setup.md—— 标准设置中的每个文件,可直接复制使用。references/messages-and-keys.md—— 命名空间结构、键命名、ICU 语法、日期/数字/列表格式化、类型化消息。references/seo.md—— hreflang、<html lang>、本地化的generateMetadata、规范、站点地图替代、语言切换器 SEO 规则。references/migration.md—— 改造现有应用、重定向、回滚。
脚本
scripts/find-untranslated.sh—— 标记项目中可能硬编码的用户面向字符串,以便你在开始之前评估工作量。作为改造的第一步运行。scripts/run-trigger-eval.sh—— 衡量这个技能的description是否在正确的提示上触发。更改描述时使用它。
测试描述
description 是 AI 代理在决定加载此技能之前唯一看到的东西。如果它不能触发,下面的所有内容都不重要。
bash scripts/run-trigger-eval.sh --dry-run # 显示计划,不进行 API 调用
bash scripts/run-trigger-eval.sh --split train # 12 个查询 x 3 次运行
bash scripts/run-trigger-eval.sh --split validation --out report.json
查询集:evals/trigger-queries.json —— 12 个应该触发,8 个不应该触发,按 60/40 分成固定的训练和验证集。
编辑描述时:
- 仅使用训练集修复失败。在最后使用验证集进行一次检查,看更改是否泛化 —— 如果训练集改善而验证集下降,说明你过拟合了。
- 应该触发但失败意味着描述太窄:添加查询所代表的概念,而不是查询的关键词。
- 不应该触发但失败意味着描述太宽泛:说明这个技能不涵盖什么。
- 在编辑之前重新运行。触发是非确定性的,一次运行不能证明什么。
不要从失败的查询中添加关键词 —— 这就是描述最终看起来像关键词列表并停止匹配真实提示的方式。