多语言第一步:独立产品如何做 i18n 与本地化
从一个真实的 Bug 说起
我接手过一个出海 SaaS 的 Bug:用户从日本访问价格页,看到的价格是 $9.90,但日区定价应该是 ¥1,500。排查下来发现代码里把美元符号和数字直接拼在了模板字符串里,没有走格式化管道。这个问题在英语区完全看不出来,只在非英语语言环境下暴露。
这类问题不是「翻译没做好」,而是「国际化基础没打好」。很多人把 i18n 和 l10n 混为一谈,实际上它们是两个不同阶段的工作:
| 概念 | 含义 | 时机 | 典型产出 |
|---|---|---|---|
| 国际化 (i18n) | 让代码结构能够适应不同语言和地区 | 开发阶段 | 翻译 Key 提取、Intl 格式化、路由设计 |
| 本地化 (l10n) | 针对具体语言做翻译和文化适配 | 上线前后 | 翻译文本、本地日期/货币、适配文化习惯 |
| 全球化 (g11n) | i18n + l10n 的总称 | 贯穿产品生命周期 | 多语言产品完整落地 |
多数独立产品的第一版出海,往往两件事混在一起做,结果改一处翻译就破坏一处布局,加一种语言就要跑一遍全站回归。
我在过去一年把两个独立产品从零做到支持 5 种语言,踩了不少坑。这篇文章把我摸索出来的路径整理出来,重点讲「第一步怎么做」——选什么框架、文案怎么组织、路由怎么设计、SEO 怎么配合。
选框架:为什么我最终选了 next-intl
React 生态里的 i18n 框架很多,主流选择有 react-i18next、react-intl(FormatJS)、next-intl 和较新的 Intlayer。它们的设计目标和适用场景不同,直接决定了后续维护成本。
| 维度 | react-i18next | react-intl (FormatJS) | next-intl | Intlayer |
|---|---|---|---|---|
| 定位 | 框架无关的通用方案 | 框架无关,ICU 消息语法 | 专为 Next.js App Router 设计 | 类型优先的声明式方案 |
| Next.js App Router 兼容 | 需手动适配,无官方路由集成 | 需额外配置 | 原生支持,官方推荐 | 原生支持 |
| 翻译格式 | JSON / YAML / 自定义后端 | JSON (ICU MessageFormat) | JSON (ICU 子集) | TypeScript 声明式 |
| TypeScript 支持 | 需配置类型推导 | 中等 | 开箱即用的类型安全 | 类型推导极强 |
| 插件生态 | 丰富(后端、缓存、检测) | 一般 | 精简够用 | 较少 |
| 周下载量(2026.6) | ~3.5M | ~2.8M | ~800K | ~50K |
| 适合场景 | 大型团队、已有 i18n 基础设施 | 需要完整 ICU 语法(复数、性别) | Next.js 项目快速上手 | 追求类型安全的中小项目 |
我做选择时主要考虑三点:
- App Router 原生支持——
next-intl从第一天就为 App Router 设计,react-i18next则需要自行处理 Middleware 和路由前缀,配置量大。 - 类型安全——独立产品迭代快,翻译 Key 拼错如果只能运行时发现,修复成本太高。
next-intl和Intlayer都能在编译期捕获。 - 心智负担——一个人维护多语言,没有精力搞复杂的插件链。
next-intl的设计哲学是「一个 Provider、一个文件夹、一个 Hook」,够用了。
如果你已经用 react-i18next 并且跑得稳,没必要迁移。但如果你从零开始,且技术栈是 Next.js App Router,我推荐先看 next-intl。
案例一:文案散落各处导致翻译遗漏
这是我犯的第一个错误。产品初期赶进度,UI 文案直接写在组件里:
// ❌ 坏做法:文案硬编码在组件中
function PricingCard({ plan }: { plan: Plan }) {
return (
<div className="card">
<h3>{plan.name}</h3>
<p>每月 {plan.price} 元</p>
<button>立即订阅</button>
<small>随时取消,无隐藏费用</small>
</div>
)
}这种做法的问题不只是「没法翻译」,更麻烦的是:当你想加日语时,根本不知道全站有多少地方写了中文。我当时用 grep 搜了一遍,漏掉了表单 placeholder、Toast 提示和邮件模板里的文本。
修复方式是把所有用户可见文本提取到翻译文件:
// ✅ 好做法:使用翻译函数,文案集中管理
import { useTranslations } from 'next-intl'
function PricingCard({ plan }: { plan: Plan }) {
const t = useTranslations('pricing')
return (
<div className="card">
<h3>{plan.name}</h3>
<p>{t('monthlyPrice', { price: plan.price })}</p>
<button>{t('subscribe')}</button>
<small>{t('cancelAnytime')}</small>
</div>
)
}对应的翻译文件结构:
// messages/zh.json
{
"pricing": {
"monthlyPrice": "每月 {price} 元",
"subscribe": "立即订阅",
"cancelAnytime": "随时取消,无隐藏费用"
}
}
// messages/en.json
{
"pricing": {
"monthlyPrice": "${price}/month",
"subscribe": "Subscribe",
"cancelAnytime": "Cancel anytime, no hidden fees"
}
}关键差异:翻译文件里不只有静态文本,还有带占位符的动态内容({price})。占位符的顺序在不同语言中可以不同——英语把价格放前面($9.90/month),中文放后面(每月 9.90 元),翻译者自己调整语序,开发不用改代码。
案例二:日期和数字格式没走本地化管道
前面提到的日本价格 Bug 就属于这类问题。根源是我在模板里直接拼接了字符串,没有使用 Intl API:
// ❌ 坏做法:手动拼接日期和货币
function OrderSummary({ date, amount }: { date: Date; amount: number }) {
return (
<div>
<p>订单日期:{date.getFullYear()}/{date.getMonth() + 1}/{date.getDate()}</p>
<p>金额:${amount.toFixed(2)}</p>
</div>
)
}这段代码在英语环境勉强能用(虽然日期格式也不是英语习惯),但到了德语区会出大问题——德国的日期格式是 DD.MM.YYYY,数字小数点用逗号(9,90 而不是 9.90)。
修复方式是使用 next-intl 内置的格式化函数,底层调用 Intl.DateTimeFormat 和 Intl.NumberFormat:
// ✅ 好做法:使用 next-intl 的格式化 Hook
import { useFormatter, useTranslations } from 'next-intl'
function OrderSummary({ date, amount }: { date: Date; amount: number }) {
const format = useFormatter()
const t = useTranslations('order')
return (
<div>
<p>{t('orderDate', { date: format.dateTime(date, { dateStyle: 'long' }) })}</p>
<p>{t('amount', { amount: format.number(amount, { style: 'currency', currency: 'USD' }) })}</p>
</div>
)
}
// messages/de.json
// {
// "order": {
// "orderDate": "Bestelldatum: {date}",
// "amount": "Betrag: {amount}"
// }
// }
// 德语输出:Bestelldatum: 26. Juni 2026 / Betrag: $9,90这里的核心原则是:格式化规则跟着 locale 走,不跟着开发者写死。Intl.NumberFormat 会根据当前 locale 自动选择小数点符号、千分位分隔符和货币符号位置,你只需要传入数值和货币代码。
案例三:SEO 元信息没有按语言独立生成
多语言上线后一个月,我发现 Google 收录的日文页面里,<title> 和 <meta description> 还是英文的。原因是我在 layout.tsx 里统一写了 metadata,没有按 locale 切换:
// ❌ 坏做法:所有语言共用同一套 metadata
export async function generateMetadata() {
return {
title: 'My SaaS - The Best Tool for Teams',
description: 'Boost your team productivity with our AI-powered tool.',
alternates: { canonical: 'https://mysaas.com' },
}
}修复后每个 locale 有独立的 metadata,并且加上了 hreflang 标签告诉搜索引擎不同语言版本的对应关系:
// ✅ 好做法:按 locale 生成独立 metadata + hreflang
import { getTranslations } from 'next-intl/server'
import type { Metadata } from 'next'
const LOCALES = ['en', 'zh', 'ja', 'de'] as const
export async function generateMetadata({
params,
}: {
params: { locale: string }
}): Promise<Metadata> {
const t = await getTranslations({ locale: params.locale, namespace: 'seo' })
const alternates = Object.fromEntries(
LOCALES.map((loc) => [loc, `https://mysaas.com/${loc}`])
)
return {
title: t('homeTitle'),
description: t('homeDescription'),
alternates: {
canonical: `https://mysaas.com/${params.locale}`,
languages: alternates,
},
}
}hreflang 的作用不只是 SEO——它还能让搜索引擎把用户导向正确的语言版本。如果一个德语用户在 Google 搜索到你的产品,hreflang="de" 会帮助 Google 直接展示德语 URL 而不是英语。
从翻译到上线的完整流程
把三个案例的经验汇总,我把多语言落地的技术结构画成了流程图:
这个流程的关键控制点在 CI 检查。我在 package.json 里加了一个脚本,在每次提交前检查翻译文件的 Key 是否对齐:
{
"scripts": {
"i18n:check": "node scripts/check-i18n-keys.mjs --strict --fail-on-missing"
}
}脚本逻辑很简单:以默认语言(英语)的 JSON 为基准,递归遍历所有 Key,检查其他语言文件是否都有对应条目。缺失的 Key 会在 CI 里报错,阻止合并。这比上线后让用户发现缺失翻译要好得多。
路由策略选择
Next.js App Router 没有内置的 i18n 路由支持(Pages Router 有),需要自己做。常见三种方案:
| 方案 | URL 形式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 子路径路由 | /en/about、/zh/about | 实现简单,SEO 友好,hreflang 直观 | URL 较长 | 多数独立产品首选 |
| 子域名路由 | en.mysaas.com、zh.mysaas.com | 各语言完全隔离,可独立部署 | DNS 配置复杂,SSL 证书多份 | 多团队协作的大产品 |
| 域名路由 | mysaas.com、mysaas.jp | 本地化感最强 | 每个域名独立维护,成本高 | 有明确本地公司实体的产品 |
对独立产品来说,子路径路由是性价比最高的选择。next-intl 通过 Middleware 实现语言检测和重定向,配合 [locale] 动态路由段,不需要额外的域名或子域名配置。
// middleware.ts - 语言检测与重定向
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
export default createMiddleware(routing)
export const config = {
// 只匹配需要国际化的路由,跳过 API 和静态资源
matcher: ['/', '/(zh|en|ja|de)/:path*'],
}内容优先级:先翻译什么
资源有限的独立产品不可能第一天就翻译全部内容。我按用户路径和转化率影响做了分级:
| 优先级 | 内容范围 | 影响 | 建议时间点 |
|---|---|---|---|
| P0 必须翻译 | 首页、价格页、注册/登录、支付页、核心按钮、错误提示、欢迎邮件 | 直接影响转化率和用户信任 | 首版上线前 |
| P1 尽快翻译 | 帮助文档、FAQ、Onboarding 引导、密码重置邮件、隐私条款 | 影响留存和合规 | 上线后 2 周内 |
| P2 按需翻译 | 博客、高级设置、后台管理、API 文档 | 低频使用,影响较小 | 目标市场确认后 |
判断标准:用户在付费路径上能遇到的所有文本都属于 P0。用户如果在支付页面看到半中半英的界面,信任感会断崖式下降。
翻译维护的工作流
翻译不是一次性任务,而是持续过程。我的做法:
- 开发时写英文 Key——每次新增功能同步更新翻译文件,不积攒。
- 用 Crowdin 或 Localazy 做翻译管理——把 JSON 文件同步上去,翻译者(可能是外包、可能是社区志愿者)在平台上操作,完成后自动同步回仓库。
- AI 辅助初稿——用 GPT-4 或 DeepL API 生成翻译初稿,但必须有人审校。机器翻译在产品名、术语一致性和语气上经常出错。
- CI 做质量卡点——检查 Key 完整性、占位符是否匹配、翻译长度是否合理(某些语言的翻译会比英语长 30%-50%,可能撑破 UI)。
Localazy 对独立开发者比较友好,有免费计划,支持 GitHub Action 自动同步。Crowdin 也有开源项目免费计划。两者都支持翻译记忆(Translation Memory),翻译过的内容可以复用到新 Key 上,减少重复劳动。
检查清单
按落地阶段分组,每项可直接执行:
阶段一:基础设施搭建
- 选定 i18n 框架,配置翻译目录结构(如
messages/{locale}.json) - 在 Middleware 中实现语言检测和重定向逻辑
- 配置
[locale]动态路由段,确保所有页面在每种语言下可访问 - 设置默认语言的回退策略(翻译缺失时不白屏)
阶段二:文案提取与格式化
- 全站搜索硬编码文案,迁移到翻译文件
- 所有日期使用
Intl.DateTimeFormat或框架封装函数格式化 - 所有数字/货币使用
Intl.NumberFormat或框架封装函数格式化 - 动态文案使用占位符,不手动拼接字符串
阶段三:SEO 与元信息
- 每种语言有独立的
<title>、<meta description>和 canonical URL - 配置
hreflang标签,声明语言版本对应关系 - 生成多语言
sitemap.xml,每种语言独立 URL - Open Graph 和 Twitter Card 标签按语言适配
阶段四:CI 与持续维护
- 添加翻译 Key 完整性检查脚本,CI 不通过则阻止合并
- 配置翻译管理平台(Crowdin / Localazy)与仓库自动同步
- 新增功能时同步更新翻译文件,不积攒
- 定期审查翻译长度对 UI 布局的影响(尤其是德语、芬兰语)
阶段五:用户体验
- 提供语言切换入口,切换后保持当前页面语义
- 首次访问时根据浏览器语言自动选择,但允许手动覆盖
- 表单错误提示、Toast 通知、邮件模板全部覆盖目标语言
参考资料
- Next.js Official Documentation: Internationalization (i18n) — Next.js App Router 国际化官方指南,涵盖路由策略、Middleware 配置和渲染行为。
- next-intl Official Documentation — next-intl 库的官方文档,包括 App Router 集成、翻译管理、格式化函数和类型安全配置。
- W3C: 本地化与国际化有什么关系? — W3C 对 i18n 和 l10n 概念的基础定义,以及全球化开发模式的核心要求。
- Crowdin: SaaS Localization: How to Translate Software in 2026 — SaaS 产品本地化的完整指南,涵盖字符串外部化、UI 弹性设计和翻译工作流。
- LocaFlow: SaaS Localization in 2026: The Complete Founder's Guide — 面向独立创始人的本地化实战指南,讨论如何用有限预算进入多个市场。
- i18next Official: Comparison to Others — i18next 官方对各主流框架的对比分析,客观说明各方案的取舍。
- Intlayer: next-i18next vs next-intl vs Intlayer: 2026 Comparison — 2026 年 Next.js i18n 框架横评,包含 App Router 兼容性、TypeScript 支持和社区反馈。
- MDN: Intl - JavaScript 国际化 API — JavaScript 原生 Intl API 文档,
Intl.DateTimeFormat、Intl.NumberFormat等格式化器的权威参考。