Skip to content
Web Development25 September 2026 · 10 min read

Five-Language Next.js Sites Without Content Drift

Five locales drift in five ways and none of them throws an error. The App Router architecture, the type-check blind spot, and the build checks that catch drift before a crawler does.

Five-Language Next.js Sites Without Content Drift

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

A risograph-printed diagram of one thick blue stem splitting into five identical parallel columns of stacked blue, pink and mustard blocks on torn cream paper

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

Five stacked bands of small printed blocks held on a metal pin, with one block missing from the middle pink band leaving a clean empty gap

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.

SurfaceHow it driftsCheck that catches it
DictionariesA key exists in the source locale onlyKey-set diff against the source, failing the build on any difference
RoutesA page is added to one locale's content mapCount generated routes per locale and assert they match
Metadata and hreflangCanonical or alternates point at the source locale everywhereParse each locale's built HTML and assert a self-inclusive alternate set
Forms and validationLabels translated, error strings left in the source languageRender and submit the form in every locale, snapshot the strings
Legal pagesPrivacy or terms updated in one language after a policy changeA 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.

  1. 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.
  2. Count the generated routes per locale. If the source locale produces 31 pages and Greek produces 30, something is conditional that should not be.
  3. 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.
  4. 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.
  5. Gate indexability on completeness. A half-translated locale carries noindex until it is finished. Drive it from one per-locale flag so nobody has to remember.
  6. 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?

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

How do you set up i18n in the Next.js App Router?

Put every route under a single app/[lang] dynamic segment, load one dictionary per locale on the server, and list your locales in generateStaticParams. The App Router has no built-in i18n config, so routing and locale detection are yours to implement. The official Next.js internationalization guide shows a getDictionary helper that dynamic-imports one JSON file per locale, which keeps translation files out of the client bundle because server components never ship them. Locale negotiation from the Accept-Language header now lives in proxy.ts following the Next.js 16 rename. If your locale list is fixed, prerender all of them and skip runtime negotiation entirely.

What is content parity on a multilingual website?

Content parity means every locale version of a route carries the same sections, claims, metadata and interactive surfaces, differing only in language. It is stricter than having all pages translated. A page can be fully translated and still lack parity if the source language gained a section last month, or if its canonical tag points at a different locale. Parity is structural, which is why it is checkable: identical key sets across dictionaries, identical generated route counts per locale, identical alternate sets per route. Treat it as a build assertion rather than an editorial goal, because editorial goals lose to deadlines and build assertions do not.

How do you add hreflang and locale alternates to a Next.js sitemap?

Return an alternates.languages object on each entry in app/sitemap.ts, mapping every locale code to its fully-qualified URL. Next.js emits those as xhtml:link rel="alternate" elements inside each URL block, and the feature has shipped since v14.2.0 according to the sitemap file convention reference. Generate the map from one shared function so generateMetadata and the sitemap cannot disagree, because a mismatch between in-page hreflang and sitemap alternates is worse than shipping only one of them. Include a self-referencing entry for every locale plus an x-default. Maintaining this by hand across five locales is how return links go missing.

Can TypeScript validate translation dictionaries across locales?

Not on its own, because the usual setup types your message keys against one source locale and never looks at the others. The next-intl type-safety workflow augments its config interface with typeof messages imported from the source JSON, which catches a call to a key that exists nowhere. It says nothing about whether the German file has the keys the English file has. You get cross-locale safety by typing each dictionary as Record<SourceKey, string> so a missing key becomes a compile error, or by running a key-set diff in CI. A cast on the import removes even the source-locale checking.

Should an unfinished locale be indexable?

An unfinished locale should carry noindex until every section is translated, then flip to indexable in one place that the sitemap reads too. Half-translated pages compete with your finished ones, and a page rendering source-language content at a /de/ URL is a duplicate rather than a localization. Drive it from a single per-locale published flag that both the metadata function and the sitemap generator read, so a locale cannot sit in the sitemap while carrying noindex. That contradiction is a common audit finding and it is entirely avoidable. When the locale is done, one boolean makes it indexable and adds it to the sitemap at the same moment.