Next.js 15 多语言站点的 6 个坑:从 query string 路由到 URL 路径

项目背景:我用 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,原因是:

  1. SEO 完美(每个语言版本有独立 URL)
  2. Next.js 原生支持 [locale] 动态路由
  3. 配合 generateStaticParams() 可以静态预渲染
  4. 后续加第三种语言只需加一个 string

坑 1:Root Layout 嵌套

现象

切换语言后页面报错或白屏。

根因

Next.js 15 的 App Router 中,app/layout.tsxapp/[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 变了但页面内容没更新。

根因

I18nProvidersetLocale 最初只是改了 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...
}

分批次推进:

  1. 先搞定 Header/Footer 等全局组件的翻译
  2. 然后批量处理工具页面的 metadata(脚本生成)
  3. 再处理各工具组件内部的 UI 文本(placeholder、label 等)
  4. 最后处理 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 ⭐⭐⭐⭐

核心经验:

  1. 多语言必须用独立 URL --- 搜索引擎需要能区分语言版本,query string 行不通
  2. [locale] 路由是 Next.js 最正路的方案 --- generateStaticParams 配合静态导出完美工作
  3. Root layout 和 Locale layout 别重叠 --- 只让 locale layout 渲染 <html>
  4. 语言切换用硬导航 --- location.href 而非 router.push,保证 metadata 重新 SSR
  5. generateMetadata 的 params 是 Promise --- Next.js 15 的变化,忘记 await 会拿不到 locale
  6. Sitemap 需要显式生成每个语言的 URL --- 不会自动推断
  7. i18n keys 命名要规范 --- 500+ key 没有规范就是灾难
  8. alternates.languages 是 SEO 关键 --- 告诉搜索引擎每个页面的所有语言版本

📚 本文是 UtlKit 技术系列第 11 篇 --- 系列索引 →


UtlKit --- 100+ 个免费在线工具,支持中英文切换。所有上述问题都是实际开发和部署过程中遇到的,解决方案已在线上稳定运行。


如果觉得有帮助,欢迎点个⭐️。有任何问题欢迎评论讨论。

相关推荐
北墨NoLimit2 小时前
鸿蒙线程间通信怎么选:TaskPool、TaskGroup、LongTask 与 Worker 实战
typescript·harmonyos
JieE2123 小时前
Next.js App Router 全栈实战:从零构建一个 Markdown 笔记系统
全栈·next.js
Orange_sparkle8 小时前
从五个 TypeScript 文件看懂 Coding Agent:一次 nano-pi 学习复盘
javascript·学习·typescript
DsirNg9 小时前
React Server Components 在真实项目中的边界:哪些组件该放在服务端
性能优化·react·next.js·app router·前端架构·rsc·react server components
深海鱼在掘金1 天前
深入浅出RAG——第7章:基础篇实战:文档问答机器人
人工智能·typescript·命令行
苏灿烤鱼1 天前
Cursor 官方插件仓,许可证未声明
typescript·agent·cursor
濮水大叔1 天前
CabloyJS 强大之处不仅仅是 IoC,而是全栈资源的寻址体系
typescript·node.js·全栈
Asize1 天前
为什么写代码前要先规划组件树?Next.js + Redis 笔记系统实战
前端·javascript·next.js
小林ixn1 天前
用 Next.js 和 Redis 撸一个 Markdown 笔记系统:RSC 实战与组件化拆解
前端·redis·next.js