Next.js 双语站:怎么让 /en 输出 lang="en"(四种方案,三种是错的)
App Router 里 <html lang> 只能在根 layout 定义一次。双语站点想让 /en 输出 lang="en",我试了路由组、middleware、动态渲染、JS 修正四种路子,只有一种同时满足「静态 HTML 正确」和「不掉性能」。
一个中英双语的 Next.js 站点,/ 是中文,/en 是英文。
访问 /en,右键查看源代码,第一行:
<html lang="zh-CN">
H1 是英文,正文是英文,hreflang 也声明了 en,唯独 lang 属性是中文。
这个问题被审计报告提了三次。前两次我都解释说「App Router 限制,改起来要重构」,然后没改。第三次我改了,下面是完整过程。
先说结论
<html lang> 在 App Router 里只能由根 layout 定义,一个根 layout 只能写死一个值。要按路由切换,只有两种真正的方案:
| 方案 | 静态 HTML 正确 | 性能 | 结论 |
|---|---|---|---|
| JS 运行时修正 | ❌ | 好 | 搜索引擎/AI 看到错的 |
| 根 layout 动态渲染 | ✅ | 差 | 全站失去静态化 |
| middleware 改写 | ❌ | 好 | 改不了流式响应体 |
| 路由组两个根 layout | ✅ | 好 | 采用 |
第三种(路由组)是唯一同时满足两边的。代价是路由结构变动,需要回归测试。
为什么这个问题比看起来严重
lang 属性影响三件事:
1. 屏幕阅读器选发音
lang="zh-CN" 的页面用中文语音合成读英文内容,读出来是逐字硬拼的中式发音。对依赖屏幕阅读器的用户,英文页基本不可用。
2. 搜索引擎的语言判定
hreflang 是「这一页有其他语言版本」的声明,lang 是「这一页是什么语言」的事实。两者应该一致。不一致时 Google 通常信 hreflang,但这属于「两个信号打架」,不该让它发生。
3. AI 抓取的内容归类
Copilot、ChatGPT 这类工具在抽正文时会读 lang。标记错误会导致英文内容被归到中文索引里,检索时匹配不到。
试过的三种错解法
错解一:客户端脚本修正
第一版我加了一段内联脚本:
<script
dangerouslySetInnerHTML={{
__html: "(function(){var p=location.pathname;if(p==='/en'||p.indexOf('/en/')===0){document.documentElement.lang='en';}})();",
}}
/>
浏览器里看是修好了。但:
curl抓到的 HTML 仍然是lang="zh-CN"- Googlebot 首轮抓取不执行 JS,看到的是错的
- 屏幕阅读器多数在 JS 执行前就开始解析
只解决了「用浏览器打开时看起来对」。
错解二:middleware 改写响应
想过用 middleware 拦下 HTML 替换属性值。做不到——Next.js 的 HTML 是流式输出的,middleware 只能改响应头,不能改响应体。
要改响应体必须自己接管 stream,把整个 HTML 缓冲下来再替换,等于放弃了流式渲染的全部好处。
错解三:根 layout 动态读路径
import { headers } from "next/headers";
export default async function RootLayout({ children }) {
const headersList = await headers();
const pathname = headersList.get("x-pathname") || "/";
const lang = pathname.startsWith("/en") ? "en" : "zh-CN";
return <html lang={lang}>...</html>;
}
这个方案技术上成立,lang 也会正确输出。 但它有一个致命代价:
headers() 是动态 API。根 layout 一旦调用它,整个站点从静态生成退化为动态渲染。所有页面每次请求都要在服务端跑一遍。
对一个 18 页的内容站,这意味着:
- 失去 CDN 全页缓存
- TTFB 从 ~200ms 涨到 ~400–600ms
- LCP 跟着退
为一个 HTML 属性牺牲整站性能,不值。
正解:路由组 + 两个根 layout
Route group 的语法是目录名加括号:app/(zh)/。括号里的名字不进入 URL,只用来分组。
关键点:每个 route group 可以有自己的 layout.tsx,且当它位于顶层时,这个 layout 就是该组的根 layout,可以包含 <html> 和 <body>。
目录结构:
app/
(zh)/
layout.tsx <html lang="zh-CN">
page.tsx → /
tools/page.tsx → /tools
notes/page.tsx → /notes
(en)/
layout.tsx <html lang="en">
en/
page.tsx → /en
tools/page.tsx → /en/tools
api/ 留在根级
sitemap.ts 留在根级
注意 (en)/en/page.tsx 里的 en/ —— 路由组不产生 URL 段,所以要把 en 再写一层才能得到 /en。
两个 layout 除了 lang 和 metadata,其余完全一致:
// app/(zh)/layout.tsx
import "../globals.css";
export default function ZhRootLayout({ children }) {
return (
<html lang="zh-CN" className="...">
<body>{/* header, main, footer */}</body>
</html>
);
}
// app/(en)/layout.tsx
import "../globals.css";
export default function EnRootLayout({ children }) {
return (
<html lang="en" className="...">
<body>{/* 相同的结构 */}</body>
</html>
);
}
globals.css 的路径要改成 ../globals.css,因为 layout 下沉了一层。
四个必须处理的坑
坑 1:根级 layout.tsx 必须删掉
如果 app/layout.tsx 还在,它会被当作所有 group 的共同父 layout,和 group 内部的根 layout 冲突,构建直接报错。
先删根 layout,再建两个 group layout。
坑 2:404 页要有兜底
两个根 layout 意味着没有「唯一的根」,未被任何路由匹配的路径找不到 layout 渲染 404。
解法是在每个 group 里放一个 catch-all:
// app/(zh)/[...notfound]/page.tsx
import { notFound } from "next/navigation";
export default function CatchAll() {
notFound();
}
// app/(en)/en/[...notfound]/page.tsx
import { notFound } from "next/navigation";
export default function CatchAll() {
notFound();
}
再各自配一个 not-found.tsx:
app/(zh)/not-found.tsx → 中文 404,lang="zh-CN"
app/(en)/not-found.tsx → 英文 404,lang="en"
实测效果:
/asdf-xyz → 404,lang="zh-CN",中文 404 页
/en/asdf-xyz → 404,lang="en",英文 404 页
静态路由优先于 catch-all,所以 /tools/site-audit 这类正常路径不会被吃掉。
坑 3:hreflang 会静默丢失
这是最容易漏的一条。
原来 hreflang 写在页面的 metadata.alternates.languages 里。重构后我发现 /en 的 hreflang 标签没了——因为 /en 页面的 metadata 只写了 canonical,没写 languages,而 layout 里定义的 alternates 被页面级 metadata 覆盖掉了。
页面级 metadata 会整段覆盖 layout 的 alternates,不是合并。
修法是每个页面的 alternates 都写全:
export const metadata: Metadata = {
alternates: {
canonical: "/en",
languages: { "zh-CN": "/", en: "/en" },
},
};
验证方法:
curl -s https://your-site.com/en | grep -o 'hrefLang="[a-zA-Z-]*"'
# hrefLang="zh-CN"
# hrefLang="en"
注意属性名是驼峰 hrefLang,用小写 hreflang grep 会一个都搜不到——我在这上面浪费了十分钟,误判成「hreflang 全丢了」。
坑 4:构建后要逐个路由核对
重构后必须跑一遍完整清单。我的检查脚本:
for u in / /en /tools /notes /tools/site-audit /en/tools; do
printf "%-22s " "$u"
curl -s "https://your-site.com$u" | grep -o 'lang="[a-zA-Z-]*"' | head -1
done
期望输出:
/ lang="zh-CN"
/en lang="en"
/tools lang="zh-CN"
/notes lang="zh-CN"
/tools/site-audit lang="zh-CN"
/en/tools lang="en"
意外的性能收益
重构前后跑 Lighthouse:
| 指标 | 重构前 | 重构后 |
|---|---|---|
| LCP | 1.7s | 1.9s |
| TBT | 240ms | 60ms |
| 性能分 | 94 | 97 |
TBT 掉了 180ms。原因不是 route group 本身,而是那段 lang 修正脚本被删掉了——静态标记本来就对,不需要运行时打补丁。
所以「JS 修正」方案不只是无效,它还在拖性能。
该不该做
| 情况 | 建议 |
|---|---|
双语/多语站,且 lang 标记错误 |
做 |
| 单语站 | 不需要 |
| 页面数 100+,路由结构复杂 | 先评估回归成本,可能需要分批迁移 |
已经有 [lang] 动态路由 |
那个方案本来就能正确输出 lang,不用改 |
我这站 18 个页面,整个重构包括回归测试用了约一小时。页面越多,成本越高,但收益是一次性的、永久的。
回滚预案:改动前把整个 app/ 目录复制一份(cp -r app app.bak),出问题直接换回来重新构建。
一句话总结
lang 必须是静态 HTML 里的事实,不能靠客户端打补丁。App Router 里唯一兼顾正确性和性能的解法是路由组拆两个根 layout——代价是路由结构变动,收益是标记永久正确,而且顺手能删掉一段拖性能的脚本。