最近给一个 App Router 项目做多语言 SEO,目标很朴素:中文放根路径,英文放 /en 前缀,让 Google 把两套 URL 分别收录。路由、hreflang、canonical、sitemap 全配好了,/en 却还是倔强地渲染中文;好不容易本地正常了,一上反向代理又整站 /en 500。记录三个真实的坑。
坑一:客户端 i18n 是单例,SSR 永远是兜底语言
我的 I18nProvider 是一个模块级 i18next 单例,靠 useMemo 里 i18n.changeLanguage(lang) 切语言:
i18next.use(initReactI18next).init({ lng: fallbackLng /* cn */ });
export function I18nProvider({ lang, children }) {
useMemo(() => { if (lang) i18next.changeLanguage(lang); }, []);
return <Provider i18n={i18next}>{children}</Provider>;
}
问题:changeLanguage 是异步的,而且这是个全进程共享的单例。服务端渲染时它还没切完,首屏 HTML 就是兜底语言(中文);语言只在浏览器 hydration 之后才切。爬虫拿到的永远是中文。
修法:改成每请求新建实例,把该语言的资源内联注入并 initImmediate:false 同步生效,这样服务端首屏和客户端首帧都是同一语言,既不闪、也不会 hydration 报错:
function seed(lang, resources) {
const i = createInstance();
i.use(initReactI18next).init({
lng: lang, fallbackLng: 'cn', resources,
partialBundledLanguages: true, // 其它语言仍可按需加载
initImmediate: false, // 内联资源同步处理
});
return i;
}
服务端把当前语言资源预载好,通过 props 传给 Provider——SSR 就真按 URL 语言出 HTML 了。
坑二:middleware 的 rewrite 会「再进一次」,把你注入的 header 覆盖掉
我用 middleware 把 /en/blog 内部重写到 /blog,同时注入 x-locale=en 给页面用:
requestHeaders.set('x-locale', seoLang); // /en -> 'en'
return NextResponse.rewrite(strippedUrl, { request: { headers: requestHeaders }});
打日志才发现,一次 /en 请求 middleware 跑了两遍:
[mw] /en -> seoLang en (注入 x-locale=en,重写到 /)
[mw] / -> seoLang cn (按 / 重算,x-locale 被覆盖回 cn!)
rewrite 到 / 会让 middleware 对新路径再执行一次,第二遍按 / 算出 cn,把第一遍的 en 覆盖了。修法:识别这次「再入」——请求已经带了 x-locale 就别重算,保留它:
const inherited = request.headers.get('x-locale');
const lang = inherited === 'en' || inherited === 'cn' ? inherited : seoLang;
坑三:上了反代,rewrite 目标变成跨 scheme,Next 去「代理」它 → EPROTO 500
本地一切正常,一挂 Cloudflare/nginx,/en 全线 500。源站日志:
Failed to proxy https://localhost:8083/ [Error: write EPROTO ... packet length too long]
原因:反代带了 x-forwarded-proto: https,于是 request.nextUrl.protocol 变成 https,我用 nextUrl.clone() 造的 rewrite 目标就成了 https://localhost:8083/。可源站本身只监听 http——Next 把这个跨 scheme 的绝对 URL 当外部地址去代理,用 HTTPS 去连一个 HTTP 端口,直接握手失败。因为只有直连 http 的本地测过,反代这层一直没暴露。
修法:重写前把协议钉回 http(源站实际监听的协议),别让它跨 scheme:
const url = request.nextUrl.clone();
url.pathname = strippedPath;
url.protocol = 'http:'; // 源站监听 http,避免被当外部 TLS 代理
return NextResponse.rewrite(url, { request: { headers: requestHeaders }});
小结:一份自查清单
- SSR 语言别依赖客户端单例 + 异步
changeLanguage,要每请求实例 + 同步内联资源; - middleware 里 rewrite 会再入,注入的 header 记得防覆盖;
- rewrite 目标 URL 的 scheme 要和源站实际监听的一致,别被
x-forwarded-proto带跑偏; - 别只在
localhost直连测,一定要过一遍真实反代再说「好了」。
三个坑修完,/en 终于稳定输出 <html lang="en"> + 英文标题 + 指向自身的 canonical,Google 能把中英两套分别收录了。
License: CC BY-NC 4.0
Updated 6 hours ago
Was this article helpful? Give it a like.
0 comments


