Skip to content

fix(docs): derive the document language from the locale route segment - #181

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-168-html-lang-locale
Aug 25, 2026
Merged

hotlong merged 2 commits into
mainfrom
claude/issue-168-html-lang-locale

Conversation

@hotlong

@hotlong hotlong commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #168

Angle-bracket markup is omitted throughout — this repo's issue/PR sanitizer strips it. Element names are written in prose.

What was wrong

apps/docs/app/layout.tsx opened the root html element with a hardcoded English language attribute. That was the only such element in the app, and app/[lang]/layout.tsx never overrode it, so all six non-default locales served localized body text while declaring themselves English.

Reproduced on origin/main at ee74379 before changing anything, by serving a production build and reading the declared language off each page:

/docs            declared en   expected en
/zh-Hans/docs    declared en   expected zh-Hans
/ja/docs         declared en   expected ja
/de/docs         declared en   expected de
/es/docs         declared en   expected es
/fr/docs         declared en   expected fr
/ko/docs         declared en   expected ko

Why the shell moved down instead of the segment moving up

A layout only receives params for its own segment and the ones above it, so the root layout can never see lang. Neither way of reaching around that works here:

  • next/root-params collects root params only down to the first layout it meets walking from the top of the tree — getRootParamsImpl in next/dist/server/app-render/create-component-tree.js returns as soon as it finds a layout module. While app/layout.tsx exists, that first layout is the root one, so lang is an ordinary route param and is never exposed as a root param.
  • Reading a middleware-set request header via headers() would see the locale, but headers() is a dynamic API. It would opt all 740 prerendered pages out of static generation in order to decide one attribute.

So the document shell moves down into app/[lang]/layout.tsx, the segment that owns the locale, and the root layout becomes a pass-through that keeps the two genuinely locale-independent concerns: the global stylesheet and the default metadata.

hideLocale: 'default-locale' routing is untouched: it only hides the prefix from the public URL, and the internal route still carries the segment, which is why the prefix-less English paths still resolve a locale.

zh-Hans passes through verbatim and is not rewritten to zh-CN. This matches the sibling marketing site, whose htmlLang helper is the identity function on locale codes for the same reason.

The 404 shell

Moving the shell down leaves the root layout a pass-through, and the 404 is the one route reaching it that renders HTML rather than a redirect or a route handler — so /no-such-page briefly carried no root element at all. apps/docs/app/not-found.tsx restores it.

This file was added as an explicitly granted extension to the card's file surface, on this reasoning from review:

the card's own wording is "pick whichever keeps a single root element". After your change the 404 route has none. Restoring it is finishing the card's stated requirement, not widening scope.

Its shape is pinned by what main already rendered through the old root layout — an English root element, the same flex flex-col min-h-screen body class, wrapping the Next.js built-in 404 — deliberately matched rather than improved. en is hardcoded because this boundary sits above the locale segment and cannot read it, which is exactly the position the old root layout was in. app/global-not-found.tsx would be the tidier convention but sits behind the experimental.globalNotFound flag, and flipping an experimental flag is out of scope here.

The surface is app/layout.tsx, app/[lang]/layout.tsx, app/not-found.tsx and nothing else.

Verification

Production build served locally at commit e04254e, one page fetched per locale, declared language read from the response body:

--- prefix-less English paths (internal rewrite) ---
/docs              http=200  declared en        expected en
/docs/architecture http=200  declared en        expected en
--- prefixed locales ---
/zh-Hans/docs      http=200  declared zh-Hans   expected zh-Hans
/ja/docs           http=200  declared ja        expected ja
/de/docs           http=200  declared de        expected de
/es/docs           http=200  declared es        expected es
/fr/docs           http=200  declared fr        expected fr
/ko/docs           http=200  declared ko        expected ko
--- non-docs pages ---
/privacy           http=200  declared en
/zh-Hans/privacy   http=200  declared zh-Hans
/terms             http=200  declared en
/ja/terms          http=200  declared ja
--- redirects unchanged ---
/en/docs           http=307 to /docs
/                  http=307 to /docs

404 route, compared against a clean origin/main worktree at ee74379:

                      main (ee74379)        this branch (e04254e)
/no-such-page   404   declared en           declared en, 404 copy present

And in next dev, which is where this mattered most, since the missing-root-layout validator runs only there:

/no-such-page  http=404  error shell: no  missing-root-layout message: no  declared en  404 copy present

Gates on e04254e: pnpm run type-check exit 0; pnpm run build exit 0, "Compiled successfully in 43s", "Generating static pages using 3 workers (740/740)". Static generation is preserved — still 740 prerendered pages, same as main.

The language guard is load-bearing, not speculative

documentLanguage() falls back to the default locale for a segment that is not a supported locale. That path is reachable and answers 200: middleware.ts's matcher skips any path containing a dot, so /foo.bar/privacy reaches the layout with lang set to foo.bar.

Ablation against the committed state, mutation confirmed on disk by object-hash change plus grep counts before reading anything, then restored and re-confirmed:

control  (guard present)  /foo.bar/privacy -> declared en
ablated  (guard removed)  /foo.bar/privacy -> declared foo.bar   (invalid tag on a live 200)
restored (guard present)  /foo.bar/privacy -> declared en

Without the guard an arbitrary URL segment becomes the document's declared language.

Two pre-existing defects found while verifying, neither touched here

Both were measured on a clean origin/main worktree at ee74379 and confirmed byte-for-byte identical before and after this branch, so neither is caused by it.

                             main (ee74379)   this branch (e04254e)
/docs/no-such-page   404     blank shell      blank shell

Generated by Claude Code

claude added 2 commits August 25, 2026 15:10
The root element was opened with a hardcoded English language attribute in
app/layout.tsx, so all six non-default locales served localized body text while
declaring themselves English. That contradicts the reciprocal hreflang and
per-locale canonical URLs the site already emits, and it is what a screen reader
reads to pick a speech synthesizer.

The locale only exists as the [lang] route segment, which sits below the root
layout, so the document shell moves down to the layout that owns it. The root
layout becomes a pass-through and keeps the locale-independent concerns: the
global stylesheet and the default metadata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
Moving the shell into the locale layout left the root layout a pass-through,
and the 404 is the one route reaching it that renders HTML rather than a
redirect or a route handler. Without a shell there, a 404 carried no root
element at all, and next dev answered the missing-root-layout error.

Reproduces what main rendered for this route through the old root layout:
an English root element and the same body class, wrapping the Next.js
built-in 404. The boundary sits above the locale segment and cannot read it,
so en is hardcoded here for the same reason the old root layout hardcoded it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every page in all seven locales declares lang="en" on the root html element

2 participants