项目背景:我用 Next.js 15 搭建了一个 100+ 工具的在线站点 utlkit.com。 上线后发现国内用户和海外用户各占一半。 决定加多语言支持时我以为很简单------加个语言切换按钮就行。 实际花了 4 天,经历了 3 次方案重构。
为什么多语言比想象中难?
📚 本文是 UtlKit 技术系列第 11 篇 --- 系列索引 →
- 上一篇:暗色模式 →
一个 100+ 页面的工具站做多语言,不只是翻译文字那么简单。你需要考虑:
| 层面 | 问题 |
|---|---|
| 路由 | 语言切换后 URL 不变?搜索引擎怎么区分中英文页面? |
| SEO | title/description/keywords 怎么双语化?canonical 怎么设? |
| 静态导出 | Next.js output: 'export' 下很多多语言方案不兼容 |
| Hydration | 不同 locale 的 SSR 和 CSR 不一致导致报错 |
| 内容量 | 104 个工具页面 × 500+ i18n keys,手动改不现实 |
我经历了从 query string → 子目录 → 路径前缀 的三次方案演进,最后选了最正统但也最复杂的 [locale] 路径前缀方案。
方案选型
方案 1:Query String(最先尝试,很快放弃)
URL 格式:/tools/bmi-calculator/?lang=zh-CN
优点:
- 改动最小,只需在路由 handler 里读 query
- 不需要重构路由结构
致命缺陷:
- SEO 灾难 --- 搜索引擎把
?lang=zh和/视为同一页面,中文内容被当作 duplicate content - 缓存问题 --- Cloudflare 默认按 URL 缓存,不带 query 的缓存会覆盖带 query 的
- 社交分享 --- 用户分享链接时 lang query 丢失
结论: 多语言站点绝对不要这么做。
方案 2:子目录(备选方案)
URL 格式:/en/tools/bmi-calculator/ 和 /zh/tools/bmi-calculator/
这是最常见的多语言 URL 模式(GitHub、Stripe 都用这个)。
优点:
- SEO 友好,搜索引擎清楚区分语言版本
- 符合最佳实践
问题:
- 和方案 3 的区别不大,只是路径层级不同
方案 3:路径前缀 [locale](最终方案)
URL 格式:/en/tools/bmi-calculator/ 和 /zh-CN/tools/bmi-calculator/
和方案 2 本质上一样,但我用了 Next.js 的 [locale] 动态路由段,而非独立的 /en/ 和 /zh/ 目录。
最终选择了方案 3,原因是:
- SEO 完美(每个语言版本有独立 URL)
- Next.js 原生支持
[locale]动态路由 - 配合
generateStaticParams()可以静态预渲染 - 后续加第三种语言只需加一个 string
坑 1:Root Layout 嵌套
现象
切换语言后页面报错或白屏。
根因
Next.js 15 的 App Router 中,app/layout.tsx 和 app/[locale]/layout.tsx 都会渲染。如果两个文件都包含 <html> 和 <body> 标签,会生成嵌套的 HTML:
html
<html> <!-- app/layout.tsx -->
<html lang="zh-CN"> <!-- app/[locale]/layout.tsx -->
<body>
<body>
<!-- 实际内容 -->
</body>
</body>
</html>
</html>
双重 <html> → 双重 Hydration → 各种不可预测的 bug。
修复
Root layout 只返回 children:
tsx
// app/layout.tsx --- 只输出 metadata,不渲染任何标签
export default function RootLayout({ children }) {
return children
}
Locale layout 渲染完整 HTML:
tsx
// app/[locale]/layout.tsx --- 渲染完整的 html/head/body
<html lang={locale}>
<head>...</head>
<body>
<I18nProvider locale={locale}>
<ThemeProvider>
{children}
</ThemeProvider>
</I18nProvider>
</body>
</html>
坑 2:静态导出下 redirect() 不工作
现象
想让根路径 / 自动跳转到 /en/ 或 /zh-CN/,但 redirect() 不生效。
根因
Next.js 的 redirect() API 依赖服务端运行时。在 output: 'export'(纯静态导出)模式下,没有服务端运行时------redirect() 直接被跳过。
修复
用客户端 JS 检测浏览器语言后跳转:
tsx
// app/page.tsx
export default function RootPage() {
return (
<>
<script dangerouslySetInnerHTML={{
__html: `(function(){
var lang = (navigator.language || 'en').toLowerCase();
var target = '/en/';
if (lang.indexOf('zh') === 0) target = '/zh-CN/';
window.location.replace(target);
})()`,
}} />
{/* 无 JS 时 meta refresh 兜底 */}
<noscript>
<meta httpEquiv="refresh" content="0;url=/en/" />
</noscript>
</>
)
}
关键点:
window.location.replace()而非href--- 不产生历史记录<noscript>兜底 --- 防止无 JS 环境卡死
坑 3:generateMetadata 的 locale 传递
现象
所有工具页面的 title 都是英文,即使访问 /zh-CN/。
根因
Next.js 15 的 generateMetadata() 通过 props.params 接收参数(异步 promise):
tsx
// ❌ 旧写法,params 不是 promise
export async function generateMetadata({ params }) {
const { locale } = params // 报错或 undefined
}
修复
tsx
// ✅ Next.js 15 写法:params 是 Promise
export async function generateMetadata(props) {
const { locale, slug } = await props.params
const [en, zhCN] = await Promise.all([
import('@/lib/i18n/en'),
import('@/lib/i18n/zh-CN'),
])
const dict = { en: en.default, 'zh-CN': zhCN.default }
const t = (key) => dict[locale]?.[key] || key
return {
title: `${t(tool.nameI18n)} - UtlKit`,
description: t(tool.descI18n),
alternates: {
canonical: `https://utlkit.com/${locale}/tools/${slug}/`,
languages: {
'en': `https://utlkit.com/en/tools/${slug}/`,
'zh-CN': `https://utlkit.com/zh-CN/tools/${slug}/`,
},
},
}
}
关键点:
params是 Promise,需要await props.params- Server Component 里可以
await import()翻译文件(Tree-shaking 友好) alternates.languages告诉搜索引擎每个页面的多语言版本
坑 4:语言切换后内容不刷新
现象
用户从 Header 切换语言,URL 变了但页面内容没更新。
根因
I18nProvider 的 setLocale 最初只是改了 React state:
tsx
// ❌ 只改 state,不刷新页面
const setLocale = (locale) => {
setLocaleState(locale)
setStoredLocale(locale)
}
在 [locale] 路由模式下,语言变化需要导航到不同 URL (从 /en/xxx 到 /zh-CN/xxx),不能只改 state------因为服务端渲染的 SEO metadata 是基于 URL 的。
修复
setLocale 改为 URL 导航:
tsx
const setLocale = useCallback((newLocale) => {
if (typeof window === 'undefined') return
const currentPath = window.location.pathname
const parts = currentPath.split('/')
const firstSegment = parts[1]
if (firstSegment === 'en' || firstSegment === 'zh-CN') {
// 在 [locale] 路由下,替换 URL 中的语言段
const afterLocale = currentPath.slice(firstSegment.length + 1)
const newPath = '/' + newLocale + (afterLocale ? '/' + afterLocale : '')
window.location.href = newPath // 硬导航,重新 SSR
return
}
// 兜底:root 路由
setLocaleState(newLocale)
setStoredLocale(newLocale)
}, [])
关键决策:用 window.location.href 硬导航而非 router.push --- 因为需要重新 SSR 以更新 metadata。
坑 5:Sitemap 不支持多语言
现象
Sitemap 只生成了英文 URL,中文页面没被收录。
根因
app/sitemap.ts 默认生成单语言 URL:
ts
// ❌ 只有英文 URL
{ url: 'https://utlkit.com/tools/bmi-calculator/' }
修复
为每个页面生成双语 URL:
ts
// app/sitemap.ts
export default function sitemap() {
const baseUrl = 'https://utlkit.com'
const locales = ['en', 'zh-CN']
const entries = [{ url: baseUrl, priority: 1 }] // 根 URL
// 静态页面:en + zh-CN
for (const locale of locales) {
for (const slug of ['about', 'privacy', 'terms', 'contact']) {
entries.push({
url: `${baseUrl}/${locale}/${slug}/`,
priority: 0.5,
})
}
}
// 工具页面:104 个 × 2 语言 = 208 条
for (const locale of locales) {
for (const tool of tools) {
entries.push({
url: `${baseUrl}/${locale}/tools/${tool.slug}/`,
priority: locale === 'en' ? 0.8 : 0.6,
})
}
}
return entries
}
最终生成 213 条 URL(102 工具 × 2 + 4 静态 × 2 + 根路径 + sitemap 页面 × 2)。
额外注意: Sitemap 页面的 <link> 也需要 locale-aware,否则会链接到旧路径。
坑 6:500+ i18n keys 的翻译工作量
现象
104 个工具页面,每个页面有 title、description、placeholder、label、tooltip、FAQ 等,需要翻译的文本远超预期。
解决方案
建立统一的 i18n key 命名规范:
ts
// tools.ts --- 工具定义自带 i18n key
{
nameI18n: 'tools.bmi.title', // 对应 en: 'BMI Calculator'
descI18n: 'tools.bmi.description', // 对应 en: 'Calculate your BMI...'
}
用脚本批量处理:
js
// scripts/fix-tool-metadata.js
// 批量更新 102 个工具页面的 page.tsx,从硬编码改为 i18n
const tools = require('../src/lib/tools')
const fs = require('fs')
for (const tool of tools) {
const pagePath = `src/app/tools/${tool.slug}/page.tsx`
// 用模板生成双语 metadata...
}
分批次推进:
- 先搞定 Header/Footer 等全局组件的翻译
- 然后批量处理工具页面的 metadata(脚本生成)
- 再处理各工具组件内部的 UI 文本(placeholder、label 等)
- 最后处理 FAQ 和特殊页面
累计约 500+ i18n keys,覆盖了所有用户可见的文本。
最终架构
scss
┌─────────────────────────────────────────────┐
│ 根路径 / │
│ 检测 navigator.language,JS 跳转到目标语言 │
├─────────────────────────────────────────────┤
│ /[locale]/layout.tsx │
│ - generateStaticParams: ['en', 'zh-CN'] │
│ - generateMetadata: 双语 title/description │
│ - 渲染完整 <html lang={locale}> │
├─────────────────────────────────────────────┤
│ /[locale]/page.tsx (首页) │
│ - 双语 metadata │
│ - Hero 区 + 工具列表 │
├─────────────────────────────────────────────┤
│ /[locale]/tools/[slug]/page.tsx (工具页) │
│ - generateMetadata: 按 locale 读取翻译 │
│ - alternates.languages: 双语 canonical │
├─────────────────────────────────────────────┤
│ I18nProvider (client) │
│ - 从 URL 路径读取 locale(SSR 传入) │
│ - setLocale: 导航到目标语言 URL │
│ - t(key): 翻译函数 │
│ - href(path): 生成带 locale 的链接 │
└─────────────────────────────────────────────┘
经验总结
| 做法 | 结果 | 推荐度 |
|---|---|---|
query string ?lang=zh |
❌ SEO 灾难 | ⭐ |
子域名 zh.example.com |
⚠️ 部署复杂 | ⭐⭐ |
路径前缀 [locale] |
✅ SEO 完美 | ⭐⭐⭐⭐⭐ |
| 只改 React state 不导航 | ❌ metadata 不同步 | ⭐ |
硬导航 location.href |
✅ 完整 SSR | ⭐⭐⭐⭐ |
核心经验:
- 多语言必须用独立 URL --- 搜索引擎需要能区分语言版本,query string 行不通
[locale]路由是 Next.js 最正路的方案 ---generateStaticParams配合静态导出完美工作- Root layout 和 Locale layout 别重叠 --- 只让 locale layout 渲染
<html> - 语言切换用硬导航 ---
location.href而非router.push,保证 metadata 重新 SSR generateMetadata的 params 是 Promise --- Next.js 15 的变化,忘记 await 会拿不到 locale- Sitemap 需要显式生成每个语言的 URL --- 不会自动推断
- i18n keys 命名要规范 --- 500+ key 没有规范就是灾难
alternates.languages是 SEO 关键 --- 告诉搜索引擎每个页面的所有语言版本
📚 本文是 UtlKit 技术系列第 11 篇 --- 系列索引 →
- 上一篇:暗色模式 →
UtlKit --- 100+ 个免费在线工具,支持中英文切换。所有上述问题都是实际开发和部署过程中遇到的,解决方案已在线上稳定运行。
如果觉得有帮助,欢迎点个⭐️。有任何问题欢迎评论讨论。