next-i18next + URL 前缀 + Cookie 偏好:营销页跟 URL,应用页跟用户(并修掉 sitemap 死循环)
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 入口,并用 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 已含语言)
- 用户进入
https://your-domain.com/ja/features/example。 - Next.js i18n 识别 URL 前缀
ja,渲染 marketing 页面并加载对应语言资源。 - 由于
localeDetection:false,站点不会再根据Accept-Language对该入口做二次重定向。 - SEO 信号(canonical/hreflang)按 URL 口径输出,保持稳定与可追溯。
场景 B:用户进入应用页(不带语言前缀),但希望跟随用户偏好
- 用户进入
https://your-domain.com/generations(示例:应用页)。 - 应用页顶层执行一次“偏好同步”:优先读 cookie
NEXT_LOCALE,否则用浏览器语言匹配并写入 cookie。 - 页面文案使用同步后的语言渲染,但 URL 不强制出现
/ja前缀。 - 应用页内部跳转默认
locale:false,避免 URL 被 Next 自动加语言前缀。
场景 C:爬虫抓取 sitemap(/sitemap.xml 与 /:locale/sitemap.xml)
- 爬虫访问
https://your-domain.com/sitemap.xml:应直接返回 200(不应发生重定向链)。 - 如果某些系统/历史链接访问
https://your-domain.com/ja/sitemap.xml:统一 301 到/sitemap.xml。 - 重定向规则通过 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 行为变化,能第一时间发现并调整规则。