Bilingual Next.js: getting lang="en" on /en (four approaches, three wrong)
In App Router, <html lang> can only be defined once by the root layout. I tried client-side patching, middleware rewriting, dynamic root layout and route groups. Only one produces correct static HTML without sacrificing performance.
A bilingual Next.js site: / is Chinese, /en is English.
Open /en, view source, first line:
<html lang="zh-CN">
The H1 is English, the body is English, hreflang declares en — only the lang attribute is wrong.
Three separate audits flagged it. The first two times I explained that App Router makes this hard and left it. The third time I fixed it. Here's how.
TL;DR
<html lang> can only come from the root layout, and one root layout hardcodes one value. Only two approaches genuinely switch per route:
| Approach | Static HTML correct | Performance | Verdict |
|---|---|---|---|
| Client-side JS patch | ❌ | fine | crawlers see the wrong value |
| Dynamic root layout | ✅ | bad | whole site loses static rendering |
| Middleware rewriting | ❌ | fine | can't modify a streamed body |
| Route groups, two root layouts | ✅ | fine | chosen |
The third is the only one that gets both. The cost is restructuring routes and regression-testing.
Why it matters more than it looks
lang drives three things:
1. Screen reader pronunciation
lang="zh-CN" on English content makes the speech synthesiser read English with Chinese phonetics — character by character. For users relying on screen readers, the English pages are effectively unusable.
2. Search engine language classification
hreflang says "another language version exists". lang says "this page is in this language". They should agree. Google generally trusts hreflang, but a contradiction between two signals is something you shouldn't ship.
3. AI retrieval bucketing
Copilot and ChatGPT read lang when extracting content. Mis-labelled pages get filed under the wrong language index and won't match English queries.
The three wrong solutions
Wrong 1: client-side patch
My first attempt was an inline script:
<script
dangerouslySetInnerHTML={{
__html: "(function(){var p=location.pathname;if(p==='/en'||p.indexOf('/en/')===0){document.documentElement.lang='en';}})();",
}}
/>
It looks fixed in a browser. But:
curlstill returnslang="zh-CN"- Googlebot's first pass doesn't execute JS and sees the wrong value
- most screen readers start parsing before JS runs
It only fixed "looks right when opened in a browser".
Wrong 2: middleware rewriting
The idea: intercept the HTML and replace the attribute. Not possible — Next.js streams HTML, and middleware can only touch headers, not the body.
To modify the body you'd have to buffer the entire response, giving up streaming entirely.
Wrong 3: dynamic root 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>;
}
This works. lang comes out correct. But:
headers() is a dynamic API. Once the root layout calls it, the entire site degrades from static generation to dynamic rendering. Every page re-renders on every request.
For an 18-page content site that means:
- no full-page CDN caching
- TTFB from ~200ms to ~400–600ms
- LCP follows
Trading site-wide performance for one HTML attribute isn't a trade, it's a loss.
The working solution: route groups with two root layouts
Route group syntax is a parenthesised directory name: app/(zh)/. The name never appears in the URL — it only groups files.
The key fact: each top-level route group can have its own layout.tsx, and when it sits at the top level, that layout is the group's root layout — free to contain <html> and <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/ stays at root
sitemap.ts stays at root
Note (en)/en/page.tsx — route groups don't produce URL segments, so a directory named en is still needed to get /en.
The two layouts differ only in lang and 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>
);
}
globals.css becomes ../globals.css because the layout moved down a level.
Four traps you will hit
Trap 1: the root layout.tsx must be deleted
If app/layout.tsx still exists, it becomes the shared parent of both groups and conflicts with the group root layouts. The build fails.
Delete the root layout first, then create the two group layouts.
Trap 2: 404s need a fallback
With two root layouts there's no single root, and unmatched paths have no layout to render a 404.
Add a catch-all inside each group:
// 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();
}
Plus one not-found.tsx per group:
app/(zh)/not-found.tsx -> Chinese 404, lang="zh-CN"
app/(en)/not-found.tsx -> English 404, lang="en"
Result:
/asdf-xyz -> 404, lang="zh-CN", Chinese 404
/en/asdf-xyz -> 404, lang="en", English 404
Static routes take precedence over catch-alls, so /tools/site-audit is unaffected.
Trap 3: hreflang silently disappears
This is the one everyone misses.
hreflang lived in each page's metadata.alternates.languages. After the restructure, /en had no hreflang tags — because that page's metadata only specified canonical without languages, and page-level metadata replaces the layout's entire alternates object rather than merging with it.
Fix: every page spells it out.
export const metadata: Metadata = {
alternates: {
canonical: "/en",
languages: { "zh-CN": "/", en: "/en" },
},
};
Verify:
curl -s https://your-site.com/en | grep -o 'hrefLang="[a-zA-Z-]*"'
# hrefLang="zh-CN"
# hrefLang="en"
The attribute is camelCase hrefLang — grepping lowercase hreflang finds nothing. I lost ten minutes to that and briefly concluded hreflang was gone entirely.
Trap 4: verify every route after deploy
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
Expected:
/ lang="zh-CN"
/en lang="en"
/tools lang="zh-CN"
/notes lang="zh-CN"
/tools/site-audit lang="zh-CN"
/en/tools lang="en"
Unintended performance win
| Metric | Before | After |
|---|---|---|
| LCP | 1.7s | 1.9s |
| TBT | 240ms | 60ms |
| Performance | 94 | 97 |
TBT dropped 180ms. Not from route groups — from deleting the lang-patching script. The static markup was already correct, so the runtime patch became dead weight.
The JS workaround wasn't just ineffective. It was costing performance.
Should you do it
| Situation | Recommendation |
|---|---|
Bilingual/multilingual, wrong lang |
Yes |
| Single language | No need |
| 100+ pages, complex routes | Assess regression cost, maybe migrate in stages |
Already using [lang] dynamic routing |
That already emits correct lang; leave it |
18 pages, roughly an hour including regression testing. More pages, higher cost — but the benefit is permanent.
Rollback plan: copy the whole app/ directory before starting (cp -r app app.bak), restore and rebuild if it goes wrong.
One line
lang has to be a fact in the static HTML, not a client-side patch. The only App Router solution that satisfies both correctness and performance is route groups with two root layouts — the cost is restructuring routes, the payoff is permanently correct markup and one less script dragging your TBT.
Does your site have these problems?
Run the free audit tool for a 30-second report, or just write to me and describe the situation — I will tell you whether it is worth fixing.