Repository navigation
fix(docs): derive the document language from the locale route segment - #181
Merged
Merged
Conversation
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
hotlong
marked this pull request as ready for review
August 25, 2026 15:36
This was referenced Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsxopened the root html element with a hardcoded English language attribute. That was the only such element in the app, andapp/[lang]/layout.tsxnever overrode it, so all six non-default locales served localized body text while declaring themselves English.Reproduced on
origin/mainatee74379before changing anything, by serving a production build and reading the declared language off each page: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-paramscollects root params only down to the first layout it meets walking from the top of the tree —getRootParamsImplinnext/dist/server/app-render/create-component-tree.jsreturns as soon as it finds a layout module. Whileapp/layout.tsxexists, that first layout is the root one, solangis an ordinary route param and is never exposed as a root param.headers()would see the locale, butheaders()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-Hanspasses through verbatim and is not rewritten tozh-CN. This matches the sibling marketing site, whosehtmlLanghelper 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-pagebriefly carried no root element at all.apps/docs/app/not-found.tsxrestores it.This file was added as an explicitly granted extension to the card's file surface, on this reasoning from review:
Its shape is pinned by what
mainalready rendered through the old root layout — an English root element, the sameflex flex-col min-h-screenbody class, wrapping the Next.js built-in 404 — deliberately matched rather than improved.enis 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.tsxwould be the tidier convention but sits behind theexperimental.globalNotFoundflag, and flipping an experimental flag is out of scope here.The surface is
app/layout.tsx,app/[lang]/layout.tsx,app/not-found.tsxand nothing else.Verification
Production build served locally at commit
e04254e, one page fetched per locale, declared language read from the response body:404 route, compared against a clean
origin/mainworktree atee74379:And in
next dev, which is where this mattered most, since the missing-root-layout validator runs only there:Gates on
e04254e:pnpm run type-checkexit 0;pnpm run buildexit 0, "Compiled successfully in 43s", "Generating static pages using 3 workers (740/740)". Static generation is preserved — still 740 prerendered pages, same asmain.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/privacyreaches the layout withlangset tofoo.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:
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/mainworktree atee74379and confirmed byte-for-byte identical before and after this branch, so neither is caused by it./foo.bar/docsthrows "Cannot use 'in' operator to search for 'root' in undefined" out of the Fumadocs page-tree lookup.notFound()below the locale segment (/docs/no-such-page,/zh-Hans/docs/no-such-page) serves a blank error shell instead of the 404 page. That boundary sits above the locale layout, soapp/not-found.tsxdoes not and cannot cover it; the fix wants anot-foundboundary inside the locale segment and its own verification. Measured identical onmainand on this branch:Generated by Claude Code