Sacred Lantern Studio
2026-11-08· 9 minNext.jsi18nApp RouterSEO

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:

  • curl still returns lang="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.