Skip to content

Why I Stopped Patching HTML lang Client-Side in Next.js

Adityo Guni Waluyo

Moving the root layout into the [locale] segment makes html lang server-rendered, deletes the client-side language bootstrap, and passes the structural audit.

TL;DR

We replaced a client-side script that guessed the reader's language with Next.js's recommended pattern: nesting the root layout under a dynamic locale segment so the lang attribute renders correctly on the server. This brought free 404s for unknown locales, static prerendering, and correct screen-reader pronunciation, even on 404 pages. Detection now lives in an Accept-Language redirect instead.

An inline script guessing the language

I opened app/layout.tsx and stared at a messy inline script. Its job was to guess the reader's language: check localStorage, fall back to browser detection, then patch the lang attribute on the <html> element before hydration finished. My first instinct was to fix the detection logic itself. Maybe the fallback fired too late, maybe the storage key was wrong. The concept was what was wrong: the document's language must be correct from the first server render, not patched in afterwards.

The pattern the docs actually prescribe

The Next.js i18n guide is explicit: nest all special files under app/[lang] so the router forwards the language parameter to every layout and page [1], and its own example renders <html lang={(await params).lang}> straight from the layout [1]. A root layout sitting under a dynamic segment is not a hack; the layout convention documentation uses exactly this internationalization case as its example [2]. The same docs remind you not to add head tags like title or meta manually in the root layout, because the Metadata API handles streaming and de-duplicating head elements [2].

What falls out of the move

Once the layout lives inside the locale segment, two built-ins come along for free. An unknown locale stops being a runtime crash: hasLocale narrows the type to the supported list and a missing translation becomes a clean 404 instead of an error [1]. And generateStaticParams can sit in the layout to prerender every language variant at build time [1], so the first visitor of /en is not the test rabbit. The parameter does not stop at the layout either: shared server utilities and deeply nested components can read the locale without prop drilling [1], and routing itself stays flexible between sub-path and domain strategies [1]. Typing helps here: LayoutProps gives the layout strongly typed params and named slots inferred from the directory structure, with types generated during next dev, next build, or next typegen [2].

The accessibility payoff

The part I underestimated: the lang attribute exists primarily so assistive technologies like screen readers can invoke the correct pronunciation [3]. A client-side patch always arrives late, which means the first readings of the page can use the wrong pronunciation rules. Declaring the language in server-rendered template closes that gap completely. After the move, our structural audit (single H1, heading levels, image alt attributes, landmarks) passed on all twenty-four pages, with no more warnings about a language declaration that appears or changes mid-load [2].

One quiet side effect sits in the hierarchy: a layout wraps everything below it, including the not-found UI [2]. With the layout inside [locale], even the 404 screen inherits the right language attribute, instead of falling back to whatever the browser guessed. Deleting the standalone root not-found file during this commit was not housekeeping; the hierarchy took over its job.

The move itself was deliberately boring. The old root layout file went away, a new one appeared inside the locale segment carrying the same fonts and theme bootstrap it already had, and the standalone not-found page at the root disappeared with it. Nothing about the visual layer changed. The only observable difference is that the document element now carries the right language on every route, and the audit tooling finally agrees with what the page claims to be.

Detection, it turns out, still has a place. The same guide suggests using the browser's language preferences in a redirect layer that reads the Accept-Language header [1]. Detection can stay dynamic. The declaration of what language the document is written in must be static, correct, and first. After this commit, ours is all three, and deleting the guessing script felt better than fixing it ever would have.

Sources

[1] Next.js docs: Internationalization guide
[2] Next.js docs: layout file convention
[3] MDN: HTML lang global attribute

Related articles