next-i18next + URL 前缀 + Cookie 偏好:营销页跟 URL,应用页跟用户(并修掉 sitemap 死循环)

2923
2026-02-28 16:23
17 小时前

next-i18next + URL 前缀 + Cookie 偏好:营销页跟 URL,应用页跟用户(并修掉 sitemap 死循环)

多语言站点最容易出现一种“看起来合理、线上非常别扭”的矛盾: 营销页希望语言体现在 URL(/ja、/ko…)——这对 SEO、分享、可追溯都更友好;应用页(登录后/工具页)更希望跟随用户偏好,不想因为 URL 语言把体验撕裂。

这篇文章是一个“实战型”的折中方案:营销页继续走 URL 前缀语言(next-i18next + Next.js i18n),应用页走用户偏好语言(Cookie 持久化 + 路由跳转默认不带 locale)。 关键是把边界划清楚,并补齐几个常见坑:自动语言重定向导致的 SEO 口径混乱,以及 sitemap.xml 在 i18n 下的重定向死循环

TL;DR(30 秒讲清楚)

  • 营销页:语言写进 URL(例如 /ko/...),不做自动语言重定向(避免“同内容多 URL”)。
  • 应用页:语言跟随用户偏好(例如 cookie NEXT_LOCALE),但 URL 不强制带前缀。
  • 导航默认规则:应用页内部跳转默认 locale:false,避免 Next 自动加语言前缀。
  • sitemap 修复:/:locale/sitemap.xml 统一 301 到 /sitemap.xml,并加“缺失 query 标记”守卫防止死循环。

问题定义(Problem Statement)

在一个同时包含营销页(SEO)与应用页(登录后体验)的 Next.js 站点中: 让营销页语言稳定体现在 URL(便于抓取与分享),让应用页语言稳定跟随用户偏好(便于留存与转化); 并避免 i18n 带来的自动重定向、URL 口径分叉、以及 sitemap.xml 的重定向死循环。

关键伪代码(读者可复现)

1) 单一真相:支持的 locales 与 defaultLocale

多语言最怕“到处写一份语言列表”。实践里建议把 locales 放在一个可被配置文件与业务代码同时读取的位置(CommonJS/JSON 都可以),并生成类型常量给前端使用。

伪代码:Locales 配置(SSOT)

module.exports = {
  defaultLocale: 'en',
  locales: ['en', 'es', 'fr', 'ja', 'ko', 'ar', 'zh-Hant' /* ... */],
};

2) 营销页:关闭 localeDetection,让 URL 成为唯一语言信号

如果开启自动语言检测,用户访问 / 可能会被重定向到 /ja/fr 等;这对体验也许“看起来贴心”,但对 SEO 来说风险很高:

  • 同一内容可能同时被 //ja 访问到,canonical/hreflang 更容易打架;
  • 外链分享、监控与日志统计会出现“同一入口多 URL”,排查难度上升;
  • 一旦规则改动,历史 URL 还会引入 301 链与抓取波动。

因此营销页建议把语言信号固定在 URL:访问哪个前缀,就输出哪个语言;根路径只输出 defaultLocale。

伪代码:Next.js i18n 基础配置

module.exports = {
  i18n: {
    locales,
    defaultLocale,
    localeDetection: false, // 关键:不根据 Accept-Language 自动跳转
  },
};

3) 应用页:用户偏好语言(Cookie)同步到 i18n(可开关)

应用页常见诉求是:用户切换语言后,下次再来仍然保持;并且不要求 URL 一定变化(尤其是登录后深链很多的产品)。 一个实用模式是用 cookie(例如 NEXT_LOCALE)存偏好,并提供一个“可关闭”的同步开关: 营销页关闭(遵循 URL),应用页开启(遵循用户偏好)。

伪代码:Cookie 偏好同步(enabled 开关)

const COOKIE_NAME = 'NEXT_LOCALE';
const DEFAULT_LOCALE = 'en';

function getCookie(name) {
  const hit = document.cookie.split(';').find((c) => c.trim().startsWith(name + '='));
  return hit ? hit.split('=')[1] : null;
}

function setCookie(name, value, maxAgeSeconds = 31536000) {
  document.cookie = `${name}=${value}; path=/; max-age=${maxAgeSeconds}`;
}

function matchBrowserLocale(supportedLocales) {
  const raw = navigator.language || navigator.languages?.[0] || DEFAULT_LOCALE;
  if (supportedLocales.includes(raw)) return raw;
  const lang = raw.split('-')[0];
  return supportedLocales.find((l) => l === lang || l.startsWith(lang + '-')) || DEFAULT_LOCALE;
}

// 在应用页顶层执行一次
function syncUserLocaleToI18n({ i18n, enabled, supportedLocales }) {
  if (!enabled) return;

  const saved = getCookie(COOKIE_NAME);
  if (saved) {
    if (saved !== i18n.language) i18n.changeLanguage(saved);
    return;
  }

  const detected = matchBrowserLocale(supportedLocales);
  if (detected !== i18n.language) i18n.changeLanguage(detected);
  setCookie(COOKIE_NAME, detected);
}

4) 应用页跳转:默认 locale:false,避免 URL 被自动加前缀

只要你在 Next.js 开了 i18n,router.push() 在很多情况下会“自动带上当前 locale”。 这对营销页是好事(URL 体现语言),但对应用页可能是坏事(产品路径被迫出现语言前缀,深链/权限/回调都要跟着改)。

一个工程化做法是:把路由跳转封装成统一入口,并且默认带上 { locale: false }。 这样应用页导航天然不带语言前缀;营销页若需要带 prefix,可以显式传 { locale: 'ja' } 或交给 Next 默认行为。

伪代码:路由跳转封装(默认 locale:false)

async function safePush(router, url, options = { locale: false }) {
  return router.push(url, undefined, options);
}

async function safeReplace(router, url, options = { locale: false }) {
  return router.replace(url, undefined, options);
}

5) sitemap.xml 的死循环:用“missing query 守卫”打断

i18n 打开后,/sitemap.xml 这种“语言无关资源”很容易出事:框架可能把它当成 defaultLocale 的请求,内部再做一次“推断 locale”的标记。 如果你又写了一个“把 /:locale/sitemap.xml 重定向到 /sitemap.xml”的规则,就可能出现循环。

sitemap.xml 在 i18n 下的重定向死循环:/sitemap.xml 被推断为默认语言后触发 /:locale/sitemap.xml 规则,反复跳转;通过 missing query 守卫与 locale:false 终止循环
图:解决这类问题的关键不是“再加一个 redirect”,而是加一个可命中的“守卫条件”。

伪代码:统一 sitemap 入口,并用 missing query 打断循环

async function redirects() {
  const localesPattern = buildAlternation(locales); // 把 locales 拼成可维护的正则 alternation

  return [
    {
      source: `/:locale(${localesPattern})/sitemap.xml`,
      destination: '/sitemap.xml',
      permanent: true,
      locale: false,
      // 关键:当框架内部推断 defaultLocale 时,会给 query 打一个标记;
      // 这个守卫让“被推断出来的 locale”不会再命中该规则,从而避免死循环。
      missing: [{ type: 'query', key: '__nextInferredLocaleFromDefault' }],
    },
  ];
}

真实链路:从一次访问到一次切换,发生了什么?

场景 A:用户从搜索引擎进入一个营销页(URL 已含语言)

  1. 用户进入 https://your-domain.com/ja/features/example
  2. Next.js i18n 识别 URL 前缀 ja,渲染 marketing 页面并加载对应语言资源。
  3. 由于 localeDetection:false,站点不会再根据 Accept-Language 对该入口做二次重定向。
  4. SEO 信号(canonical/hreflang)按 URL 口径输出,保持稳定与可追溯。

场景 B:用户进入应用页(不带语言前缀),但希望跟随用户偏好

  1. 用户进入 https://your-domain.com/generations(示例:应用页)。
  2. 应用页顶层执行一次“偏好同步”:优先读 cookie NEXT_LOCALE,否则用浏览器语言匹配并写入 cookie。
  3. 页面文案使用同步后的语言渲染,但 URL 不强制出现 /ja 前缀。
  4. 应用页内部跳转默认 locale:false,避免 URL 被 Next 自动加语言前缀。

场景 C:爬虫抓取 sitemap(/sitemap.xml 与 /:locale/sitemap.xml)

  1. 爬虫访问 https://your-domain.com/sitemap.xml:应直接返回 200(不应发生重定向链)。
  2. 如果某些系统/历史链接访问 https://your-domain.com/ja/sitemap.xml:统一 301 到 /sitemap.xml
  3. 重定向规则通过 missing query 守卫避免“默认语言推断”造成的死循环。

指标与验证(最小闭环)

  • 重定向链健康度:/sitemap.xml 必须是 0 跳或 1 跳(不允许无限循环、也不建议多跳)。
  • URL 口径一致性:营销页语言在 URL;应用页 URL 不强制带语言前缀(按你的产品策略)。
  • 语言稳定性:应用页刷新后语言仍保持(cookie 偏好生效)。
  • SEO 回归:Search Console 的覆盖率/重复网页/软 404 报告,不应因语言策略改动而出现明显波动(需要对比上线前后)。

通过标准(建议):

  • canonical:每页只出现 1 个,不包含 query/hash,尽量指向最终态 URL(避免 301 链)。
  • alternates:包含 x-default(如适用),只链接真实存在的语言 URL(不 404/不软 404)。
  • sitemap:抽样 URL 的 200 比例接近 100%,301 链长 ≤ 1;不应包含 noindex 或黑名单路径。

最小可复现检查清单(示例)

# 1) sitemap:根路径不应重定向死循环
curl -I https://your-domain.com/sitemap.xml

# 2) sitemap:带语言前缀的入口应统一到根路径(1 次跳转)
curl -I https://your-domain.com/ja/sitemap.xml

# 3) 营销页:URL 带 prefix 时应稳定返回 200
curl -I https://your-domain.com/ja/features/example

# 4) 应用页:带 Cookie 模拟(仅验证 Cookie 是否随请求携带;语言渲染需浏览器侧验证)
curl -I -H "Cookie: NEXT_LOCALE=ja" https://your-domain.com/generations

预期与判定(建议):

  • sitemap 根路径:/sitemap.xml 返回 200,不出现重定向链,更不应出现循环。
  • 带前缀的 sitemap:/{locale}/sitemap.xml 统一 301/sitemap.xml,链长 ≤ 1。
  • 营销页:带语言前缀的 URL 返回 200,且不依赖 cookie/自动检测做额外跳转。
  • 应用页:cookie 能随请求携带;语言渲染是否生效建议用浏览器侧验证(避免把 SSR 与客户端状态混在一起判断)。

不通过先查:是否开启了 localeDetection 导致意外跳转;redirect 是否缺少守卫条件导致循环; sitemap handler 是否被 i18n 路由优先级误命中;以及应用页是否把“URL 语言”和“偏好语言”混在同一套规则里。


FAQ

Q1:默认语言要不要也带前缀(/en)?

两种都能做,但建议选择一个并长期保持。很多团队让 defaultLocale 不带前缀(根路径 /),其好处是 URL 更短、历史兼容更好; 代价是你必须在 canonical/hreflang 上更谨慎,避免把 defaultLocale 的内容在两个 URL(//en)同时暴露出来。

Q2:为什么要关闭 localeDetection?这会不会让用户“进来不是母语”?

关闭不是为了“不做人性化”,而是为了把语言策略从自动重定向改成显式可控: 营销页用 URL 控制语言(对 SEO 更稳定),应用页用用户偏好控制语言(对体验更稳定)。 真正想做“首次访问就给母语”,也建议只在应用页做(并且要有回滚与监控)。

Q3:用户偏好应该用 Cookie 还是 localStorage?

  • Cookie:请求会自动携带,适合服务端也需要读偏好的场景;也便于跨子域(视你的域策略)。
  • localStorage:只在客户端,隐私边界更清晰;但 SSR 无法直接读取,需要额外注入或延迟生效。

本文用 Cookie 讲解,是因为它更接近“全链路可用”的持久化方式。

Q4:应用页不带语言前缀,会不会对 SEO 不友好?

大多数应用页本来就不应该被索引(登录态、私有内容、参数页等),更建议通过 noindex/robots 策略管控。 真正需要 SEO 的页面,优先用“营销页”承载,并把语言写进 URL。

Q5:为什么 sitemap 要“语言无关”?不能每个语言一份吗?

你当然可以做多语言 sitemap(甚至 sitemap index),但那是“规模化”话题。这里讨论的是最容易踩坑的基线: 如果你已经有一个全站统一 sitemap.xml,就不要让 i18n 让它变成多入口且可能循环的资源。

Q6:__nextInferredLocaleFromDefault 这种标记可靠吗?升级 Next 会不会变?

这是框架内部细节,确实存在“升级变动”的可能。因此更稳的做法是:把这类守卫写成可观测的—— 上线前用 curl 验证链路,上线后监控 /sitemap.xml 的 3xx/5xx 与抓取错误; 一旦 Next 行为变化,能第一时间发现并调整规则。