A multilingual site is not five copies of the same page; it is one system with five ways to drift. The copies are the easy part. The system is where the work lives, and where the failures sit quietly until a crawler or a build finds them for you.
Most guides to building a multilingual website with the Next.js App Router stop at routing. Add a [lang] segment, load a dictionary per locale, render. That gets you five URLs. It does not get you five pages that carry the same claims, the same metadata, the same form fields and the same legal text after six months of edits to the source language.
Nobody budgets for drift, because drift does not throw. A service description gets rewritten in the source language and four translations keep describing the version from March. A privacy page ships in one locale. A route appears under [lang] and only one dictionary gets its heading key, and the compiler agrees that everything is fine.
What does content drift look like on a multilingual site?
Content drift is when locale versions of the same route stop matching in substance, structure or metadata while every page still builds and returns 200. It shows up in four shapes, and only one of them is about language.
Substance drift. The source copy changes and the translations do not. The German page still promises a service you stopped offering in spring.
Structure drift. The English page has five sections and the Greek page has four, because the fifth key was never added. The layout renders anyway, with a hole in it where a heading should be.
Metadata drift. Titles, descriptions, canonicals and alternate sets diverge until search engines see five pages making five different claims about which one is the original.
Surface drift. Forms, validation messages, error pages, cookie banners, 404 copy. The parts nobody proofreads because nobody opens them during review.
Which of those four have you shipped? None of them fails a build. Most do not fail a smoke test either, because a smoke test hits the source locale and calls it a day.
One locale segment, one page tree

The architecture for a multilingual Next.js App Router site is not complicated, and the official guidance has converged on one shape. Every route lives under a single dynamic segment, app/[lang], and the locale flows down from there. The Next.js internationalization guide spells out the whole pattern: a [lang] root, a getDictionary helper that dynamic-imports one JSON file per locale, and generateStaticParams in the root layout returning every locale you support.
That last function is what turns a multilingual site into a static one. Return five locales, get five prerendered trees. If your locale list is a constant and your content is a constant, you are building a statically generated Jamstack site with no runtime language negotiation at all, which removes an entire class of caching and cookie bugs before they exist.
Two details changed recently and are worth noticing if you learned this pattern a year ago. Locale detection from the Accept-Language header now lives in proxy.ts rather than middleware.ts, following the Next.js 16 rename. And next/root-params lets any server component read lang directly instead of threading it through every layer. That second one matters more than it sounds like, because prop drilling the locale is how a deeply nested component ends up reading the wrong dictionary on exactly one route.
Both decisions rhyme with the ones in Next.js App Router patterns for SaaS products, since both come down to being strict about what is known at build time.
Your type checks only prove the source language is right
TypeScript validates message keys against whichever locale you typed as the source, so the other four dictionaries can be missing keys and still compile cleanly. This is the most expensive misunderstanding in multilingual TypeScript projects, and the standard recipe encourages it.
The next-intl TypeScript workflow documentation shows the mechanism plainly: import your source messages, then augment the library's config interface with Messages: typeof messages. You get autocomplete and a red squiggle when you call a key that does not exist. That type is derived from one file. Nothing in it looks at the other locale files.
So the compiler is happy while four dictionaries quietly go out of shape. Then somebody adds as Dictionary to a JSON import to silence an unrelated complaint, and the source locale stops being checked too. Actually, that understates it. A cast on the dictionary import converts every future missing key from a compile error into a runtime undefined, and undefined rendered into JSX is an empty string, which on screen looks indistinguishable from a design choice.
The scale this reaches in maintained projects is not small. In September 2026, Medusa's locale validation caught 551 leaf keys missing from the Bulgarian dictionary compared with English, on their development branch, in a codebase with an established translation workflow. A dedicated i18n:validate script found that gap. The compiler never could have.
Here is how that went on Elpida Solutions.
Elpida Solutions publishes Serbian, English, German, Greek and Russian routes from one Next.js structure. The dangerous part was not translating a sentence; it was keeping every dictionary, route, metadata record, form and legal page structurally identical while the source copy kept changing. A missing key can survive a TypeScript cast and fail only when static generation reaches that locale.
The full build, including the contact flow and the deployment rules that go with it, is written up in the Elpida Solutions five-language website case study.
The five surfaces that drift

Each surface drifts in its own way and needs its own check. Treating them as one problem called translation is why the same bug keeps being discovered in a different place.
| Surface | How it drifts | Check that catches it |
|---|---|---|
| Dictionaries | A key exists in the source locale only | Key-set diff against the source, failing the build on any difference |
| Routes | A page is added to one locale's content map | Count generated routes per locale and assert they match |
| Metadata and hreflang | Canonical or alternates point at the source locale everywhere | Parse each locale's built HTML and assert a self-inclusive alternate set |
| Forms and validation | Labels translated, error strings left in the source language | Render and submit the form in every locale, snapshot the strings |
| Legal pages | Privacy or terms updated in one language after a policy change | A version field per legal document per locale, compared in CI |
The fourth row is the one that embarrasses people. A contact form with translated labels and English validation errors is the clearest possible signal that nobody opened that page in that language.
How do you keep hreflang and the sitemap honest across five locales?
Every locale version must list itself and all the others, its canonical must point at itself, and the sitemap must repeat the same set. Google's rule here is mechanical and unforgiving: localized versions have to reference each other bidirectionally, and if two pages do not both point to each other the annotations may be ignored. One-way hreflang earns no partial credit.
The mistake is common enough to count as the norm. Semrush's audit of 20,000 multilingual sites, published in 2017, found 58% of them carrying hreflang conflicts in the page source, and a missing self-referencing tag appeared in 96% of those conflict cases. The study is old. The tooling has improved since and the mistake has not.
Maintaining that by hand across five locales and a few dozen routes is arithmetic nobody wins. Generate it instead. One function takes the current route and returns the full alternates map, generateMetadata calls it, and the sitemap calls the same function, so the two cannot disagree. Next.js has supported localized sitemaps since v14.2.0, and the sitemap file convention reference shows the alternates.languages shape that emits an xhtml:link per alternate inside each URL block.
Add x-default while you are in there, pointing at whatever a visitor with no matching language should be shown.
A missing translation still returns 200
A missing translation almost never returns an error. It returns a page, with a fallback string or a blank where a heading belongs, and a 200 status. Your uptime monitor is satisfied. Your analytics record a pageview. Nothing anywhere reports that the Greek homepage lost its third section. That is why these checks belong in the build, where silence is not an option.
Which checks should run before a locale goes live?
Six checks belong in CI before any locale ships: key-set diff, route count parity, a full multi-locale build, hreflang round trip, indexability gating and form rendering. Put them in CI rather than in a pre-launch ritual. A ritual happens once.
- Diff the key sets. Flatten every dictionary to a sorted list of leaf paths and compare each locale against the source. Any difference fails the build. This is about twenty lines of code and it is the highest-value check on the list.
- Count the generated routes per locale. If the source locale produces 31 pages and Greek produces 30, something is conditional that should not be.
- Build every locale, every time. Do not sample one language in CI to save two minutes of build. A missing key that appears on one route in one locale is exactly the failure static generation exists to surface, and sampling throws that away.
- Assert the hreflang round trip. Parse the built HTML, collect the alternates from each locale version of a route, then check that the sets are identical and that each includes itself.
- Gate indexability on completeness. A half-translated locale carries
noindexuntil it is finished. Drive it from one per-locale flag so nobody has to remember. - Render the forms. Submit an invalid value in each language and read the error message that comes back.
Fonts, hyphenation and the parts translation memory never sees
Greek and Cyrillic need their own font subsets, and a webfont that covers Latin beautifully can fall back to something else entirely when it meets Ћирилица. Check that on the built page, not in the design file.
German is the other one. Compound nouns run long, and a navigation item that fits at 14 characters in Serbian can be 28 in German, which breaks a header that was never tested past its source language. Then there is hyphenation, which browsers will only apply if the lang attribute on the html element is actually correct per locale, and that attribute is easy to hardcode once and forget.
Late one evening, resizing the browser with the German build loaded, I watched the language switcher itself become the widest element in the header at one breakpoint. It had been that way from the first commit. Nobody had seen it, because nobody resizes the window while a language other than the source one is on screen.
Start with the diff
Open your repo and run one thing: flatten every dictionary to a sorted list of leaf keys and diff the non-source locales against the source. Ten minutes of work, most of it in a script you keep. If it comes back clean, your parity is real and you can stop guessing about it. If it comes back with fifty missing keys, you have just found what your type checker has been agreeing with for months.
Then answer the harder question. When the source copy changes next week, what stops the other four locales from falling behind without telling anyone?
