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
<html lang>. - 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.
Why a subdirectory, not a subdomain
/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.
Two route groups, two root layouts
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:
app/
(en)/
layout.tsx ← <html lang="en">
page.tsx → /
questions/page.tsx → /questions
(fr)/
layout.tsx ← <html lang="fr">
fr/page.tsx → /fr
not-found.tsx
Each root layout renders its own <html> and <body>, header and footer:
// app/(fr)/layout.tsx
export default function FrRootLayout({ children }: { children: ReactNode }) {
return (
<html lang="fr" className={`${fraunces.variable} ${sourceSerif.variable}`}>
<body>
<HeaderFr />
<main>{children}</main>
<FooterFr />
</body>
</html>
);
}
Moving the existing English files into app/(en)/ was the whole migration. Every English URL stayed byte-for-byte the same.
The gotcha: your 404 page loses its layout
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.
The fix is to make not-found.tsx a complete document itself:
// app/not-found.tsx
import './globals.css';
export default function NotFound() {
return (
<html lang="en" className={`${fraunces.variable} ${sourceSerif.variable}`}>
<body>
<main>
<h1>Page not found</h1>
<Link href="/">← Take the test</Link>
<Link href="/fr">Version française →</Link>
</main>
</body>
</html>
);
}
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.
hreflang only where a real twin exists
Only the two homepages are true translations of each other, so only they declare alternates, with a small helper:
export function alternateLanguages(paths: { en: string; fr: string }) {
return {
languages: { en: paths.en, fr: paths.fr, 'x-default': paths.en },
};
}
// app/(en)/page.tsx and app/(fr)/fr/page.tsx
export const metadata = {
alternates: { canonical: '/', ...alternateLanguages({ en: '/', fr: '/fr' }) },
};
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 <a href="/fr" hrefLang="fr"> 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.
Ship only the language you need
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:
interface QuizVariant { // trimmed to the relevant fields
storageKey: string; // 'rpt:progress:fr' — progress never collides across versions
questions: readonly string[];
bands: readonly ScoreBand[];
ui: QuizUiStrings; // every UI string, no functions (must serialise)
sharePath: string;
}
// app/(fr)/fr/page.tsx (a server component)
<Quiz variant={FR_VARIANT} />
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 typography needs no-break spaces
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:
export const frenchSpacing = (text: string) => text.replace(/ ([?!:;])/g, '\u00a0$1');
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.)
Top comments (0)