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

8 阅读5分钟

项目背景:我用 Next.js 15 搭建了一个 100+ 工具的在线站点 utlkit.com。 上线后发现国内用户和海外用户各占一半。 决定加多语言支持时我以为很简单——加个语言切换按钮就行。 实际花了 4 天,经历了 3 次方案重构。

为什么多语言比想象中难?

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

一个 100+ 页面的工具站做多语言,不只是翻译文字那么简单。你需要考虑:

层面问题
路由语言切换后 URL 不变?搜索引擎怎么区分中英文页面?
SEOtitle/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>                    <!-- app/layout.tsx -->
  <html lang="zh-CN">     <!-- app/[locale]/layout.tsx -->
    <body>
      <body>
        <!-- 实际内容 -->
      </body>
    </body>
  </html>
</html>

双重 <html> → 双重 Hydration → 各种不可预测的 bug。

修复

Root layout 只返回 children:

// app/layout.tsx — 只输出 metadata,不渲染任何标签
export default function RootLayout({ children }) {
  return children
}

Locale layout 渲染完整 HTML:

// 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 检测浏览器语言后跳转:

// 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):

// ❌ 旧写法,params 不是 promise
export async function generateMetadata({ params }) {
  const { locale } = params  // 报错或 undefined
}

修复

// ✅ 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:

// ❌ 只改 state,不刷新页面
const setLocale = (locale) => {
  setLocaleState(locale)
  setStoredLocale(locale)
}

[locale] 路由模式下,语言变化需要导航到不同 URL(从 /en/xxx/zh-CN/xxx),不能只改 state——因为服务端渲染的 SEO metadata 是基于 URL 的。

修复

setLocale 改为 URL 导航:

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:

// ❌ 只有英文 URL
{ url: 'https://utlkit.com/tools/bmi-calculator/' }

修复

为每个页面生成双语 URL:

// 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 命名规范:

// tools.ts — 工具定义自带 i18n key
{
  nameI18n: 'tools.bmi.title',     // 对应 en: 'BMI Calculator'
  descI18n: 'tools.bmi.description', // 对应 en: 'Calculate your BMI...'
}

用脚本批量处理:

// 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,覆盖了所有用户可见的文本。

最终架构

┌─────────────────────────────────────────────┐
│ 根路径 /                                     │
│ 检测 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+ 个免费在线工具,支持中英文切换。所有上述问题都是实际开发和部署过程中遇到的,解决方案已在线上稳定运行。


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