圣灯在线
2026-11-08· 9 分钟Next.jsi18nApp RouterSEO

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——代价是路由结构变动,收益是标记永久正确,而且顺手能删掉一段拖性能的脚本。

你的站有这些问题吗?

用免费体检工具输入域名,30 秒看结果。也可以直接写信说清情况,我告诉你值不值得修。