Back to News & Insights
Web Development September 30, 2026 · 4 min read

Adding a second language to a static Next.js site without changing a single URL

My small quiz site started English-only: a Next.js App Router project, output: 'export', deployed as...

Adding a second language to a static Next.js site without changing a single URL

My small quiz site started English-only: a Next.js App Router project, output: 'export', deployed as static files. When I added a French version, I had three rules: No English URL could change. They were already indexed. Each page had to declare its real language in . No i18n middleware — a static export has no server to run it.

Here's the setup that satisfied all three, plus the one gotcha that cost me an afternoon.

/fr rather than fr.example.com or a separate domain. A subdirectory shares the domain's existing authority, so the French pages didn't start from zero in search. That turned out to matter: a few days after launch, the French pages were getting impressions faster than the English ones had.

The App Router lets a route group have its own root layout. Route groups (folders in parentheses) don't appear in the URL, so this moves nothing:

Moving the existing English files into app/(en)/ was the whole migration. Every English URL stayed byte-for-byte the same.

With a single root layout, app/not-found.tsx renders inside it and inherits your fonts, styles and header. With multiple root layouts, there is no single layout for the global 404 to sit in — Next renders it on its own. Mine silently fell back to an unstyled page with none of my CSS.

It imports the stylesheet and sets up the fonts itself. On a static export this becomes 404.html, which the host serves for any unknown path in either language, so it links to both homepages.

A related trap caught me later: every page embeds the 404's React Server Components payload. When I verified deploys by grepping the HTML for a phrase like "Version française", every page "contained" it — it came from the 404 payload, not the page. Check something unique to the page, or the build status, instead.

Only the two homepages are true translations of each other, so only they declare alternates, with a small helper:

The French-only pages (a teen version, a quick 30-question version) declare no hreflang at all. Pointing hreflang at an English page that isn't really the same content does more harm than good. The French metadata helper leaves languages out on purpose, and a test asserts it stays that way.

The language switch is just a plain in the header and footer. No JavaScript, no cookie, no redirect based on Accept-Language, so crawlers and users both see a stable page at every URL.

The quiz is a client component. If it imported both question sets, every French visitor would download the English list too. Instead each server page passes a plain-data "variant" to it:

Because the variant crosses the server/client boundary as props, it has to be serialisable. That means no formatter functions, so strings use {score} placeholders filled on the client. The English variant lives in its own module so importing it never drags the French data into the English bundle.

French puts a space before ? ! : ;, and it must not wrap onto the next line. Source strings are written with ordinary spaces for readability, then converted once:

One side effect showed up in tests. Testing Library's default text normalizer collapses whitespace, and \s includes the no-break space, so getByText(frenchString) failed even though the text was on the page. Passing { normalizer: (t) => t } for those assertions fixed it.

The site is ricepuritytestup.com, with the French version at /fr. (It's my site. The quiz asks personal yes/no questions, but your answers are scored in your browser and never sent anywhere.)

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation