Skip to content

Vaynerov Technologies

We don't just develop — we conjure every line of code & pixel.

All articlesDeep dives

Six Languages, One Codebase: Next.js i18n as Architecture

vaynerov.com/de/leistungen is not an English page with a language switch bolted on. It is a different URL, a different catalog, a different set of hreflang alternates and a carefully rationed slice of JavaScript. This is the architecture underneath six languages.

Vaynerov TechnologiesThe studio
Published Updated 10 min read
On this page
  1. The slug is part of the translation
  2. One middleware, three bypasses
  3. 1.19 MB of message catalogs, and the Russian problem
  4. Eleven namespaces reach the browser
  5. Translations that change without a deploy
  6. Fallbacks, hreflang, and what the crawlers get

Look at the URL: vaynerov.com/de/leistungen. Nothing in it is English. The locale segment says German and so does the slug — Leistungen, services. A visitor who arrives from a German search result never sees an English word in the address bar, and a crawler that indexes that page indexes a German path, not an English one wearing a ?lang=de costume.

That single decision — that the slug is part of the translation — is why internationalization on this site is an architecture problem rather than a translation problem. Translation is a spreadsheet. Architecture is what happens when one canonical route has to become six URLs, six sitemap entries, six hreflang alternates and one shared React tree, without doubling the code or tripling the bytes that reach a phone.

6
locales — en, ru, es, de, fr, pt
28
localized pathname entries
1.19 MB
message catalogs on disk
11
namespaces shipped to the browser

The measured shape of the i18n layer: what is routed, what is stored, and the small fraction of it that a browser ever downloads.

The slug is part of the translation

We run next-intl with localePrefix: "as-needed" and English as the default locale. English URLs carry no prefix at all — /services is the canonical services page — while the other five locales are always prefixed. The trade is deliberate: as-needed costs a routing decision on every single request, and buys clean, prefix-free canonical URLs for the locale that most links point at.

On top of the prefix sits the pathnames map: 28 entries, each pairing one internal route with six public slugs. Services, work, pricing, the journal, about, careers, investors, contact, quote, privacy and terms all translate.

const pathnames = {
  "/services": {
    en: "/services",
    ru: "/uslugi",
    es: "/servicios",
    de: "/leistungen",
    fr: "/services",
    pt: "/servicos",
  },
  // …27 more
};

One route, six URLs. French happens to land on the same word as English — harmless, because the locale prefix disambiguates: /services and /fr/services are different pages.

Routeruesdefrpt
servicesuslugiserviciosleistungenservicesservicos
workrabotytrabajosprojekterealisationsprojetos
pricingtsenypreciospreisetarifsprecos
articlesstatiarticulosartikelarticlesartigos

Four of the 28 pathname entries. The Russian slugs are Latin transliterations, so every path on the site stays ASCII in a log line, a search result and an analytics dashboard.

The interesting part of the table is what is missing from it. A handful of routes never translate: /museum, /games, /roadmap, /cookies, /nda. Two different reasons, both worth stating out loud. Product codenames are names — the Reliquary is called the Reliquary in every language, and a German visitor searching for it types the name we gave it, not a translation of it. Legal boilerplate is the opposite case: those pages change rarely, are read by lawyers and crawlers more than by humans, and a stable path across all six locales is worth more than a localized one.

One route in, six URLs out. English skips the prefix by policy; product codenames and legal pages skip the translation by policy.

One middleware, three bypasses

Every request meets a hand-rolled router wrapped around createIntlMiddleware. Most of them go straight through to the locale negotiation. Three prefixes do not. /admin* is handed to updateSession so the Supabase auth cookie gets refreshed; /api* and /pitch* are passed to next() untouched, because neither has any business being redirected to a locale it never asked for. The matcher itself excludes _next and anything with a dot in it, so static assets never wake the middleware at all.

The bypass that pays for itself is the session one, and the reasoning sits in a comment right next to it: no public page reads the Supabase session, so running updateSession on public traffic would buy an auth round-trip per request and nothing else. It is a deliberate coupling rather than an oversight — the day a public page needs a logged-in user, that comment is the thing that has to be revisited first, which is exactly why it is a comment and not a silence.

1.19 MB of message catalogs, and the Russian problem

Six JSON catalogs hold every string on the site. They are not the same size.

LocaleCatalog bytesvs English
en170,069—
es186,854+10%
pt187,552+10%
de188,041+11%
fr193,393+14%
ru260,610+53%

Message catalog sizes measured with wc -c. Total across six locales: 1,186,519 bytes.

Four of the five translations cluster ten to fourteen percent above English, which is roughly the expansion any Romance or Germanic translation gives you for free. Russian sits at +53%, and it is worth being precise about why, because the number flatters the wrong conclusion: a large part of that gap is UTF-8 arithmetic. Cyrillic characters cost two bytes each where Latin costs one, so a Russian sentence of identical length lands at double the bytes before a single extra word is added. Russian is also genuinely wordier than English — but the byte count measures encoding and prose together, and pretending otherwise would make our own charts lie to us.

The size matters because of how the catalogs are loaded: the request config imports them wholesale, per request. That is a fine deal for interface strings and a terrible one for long-form prose, which is why the article you are reading does not live in a message catalog at all. Journal pieces are typed content modules under src/content/articles/<slug>/<locale>.ts, imported only by the article routes. Long copy in the catalogs would tax every page on the site for text that appears on one.

Eleven namespaces reach the browser

Server components translate on the server; the strings never leave the render. Client components cannot do that, and the naive fix — hand the whole catalog to NextIntlClientProvider — serialized upwards of 10 KB of strings that no client component on the page would ever read, into the RSC payload of every page.

So the provider gets a hand-picked subset. A pickMessages helper resolves dotted paths against the catalog, and a single GLOBAL_CLIENT_NAMESPACES list — eleven entries — decides what a browser is allowed to see. Some of those entries are not whole namespaces but individual leaves: only three leaves of pages.services ship, because three of them are needed by an interactive component and the rest of that namespace is rendered on the server. The maintenance procedure for keeping that list honest is a grep, documented in the file next to the list, which is the least glamorous and most reliable form of documentation we know.

Every string that reaches the browser is there because someone decided it should be. There is no default of "ship everything".

The exception is the Lab. Game components are text-heavy in a way marketing pages are not — the games namespace runs to roughly 150 keys for a single game — and none of it belongs in the global allowlist. So the games subtree mounts its own nested provider carrying its own namespace, which is where a genuinely nasty trap lives.

Translations that change without a deploy

Static catalogs are the floor, not the ceiling. Copy for CMS-driven surfaces can be overridden from the database, so a wording fix in Spanish does not need a build. The merge is deliberately careful: overrides are applied with setNestedValue onto a structuredClone of the imported catalog, never onto the module object itself — that object is shared across every request in the process, and mutating it would make one visitor's override everybody's. The read sits behind unstable_cache tagged i18n-overrides with a five-minute window, and admin writes bust the tag rather than waiting it out.

If the database is unreachable, a bare catch drops back to the static JSON and the page renders. That is the right behaviour and it also produced the best bug in this system's history.

Calling cookies() inside unstable_cache throws in Next 15+. The cached override reader was doing exactly that. The throw landed in the bare catch, the static catalog was served, every page looked perfect — and the override feature had been silently dead.

Overrides saved fine. They cached fine. They simply never appeared, and nothing anywhere reported an error, because the fallback did precisely the job it was written to do. The fix is small — the override read uses the cookie-free public Supabase client, which has no business reading cookies in the first place — but the lesson outlived it. We now treat a silent fallback as an unfinished one: if a catch swallows a failure that changes what the user sees, it has to leave a trace somewhere, or it is not a safety net, it is a blindfold.

A fallback that can't tell you it fired will eventually hide a dead feature.

One consequence of overrides reaches back into the client payload: the provider is fed from getMessages() rather than a raw JSON import. Importing the JSON directly would be marginally faster and would also bypass the merge entirely, so client components would sit there rendering the pre-override text while the server-rendered half of the same page showed the corrected copy. Two sources of truth on one screen is worse than a slow one.

Fallbacks, hreflang, and what the crawlers get

Translation coverage is never uniform. A CMS section might exist in English and German but not yet in Portuguese, and the honest options are a blank region or English text. We chose English, at the content layer: fetchSectionWithFallback, fetchRowsWithFallback and fetchPageMeta each try the requested locale, retry English on a miss, and short-circuit the retry when the requested locale already is English. A partially translated page degrades to a bilingual page rather than a broken one.

The alternates map does not know about any of that, and that is on purpose. alternates.ts declares the URL set — six hreflang entries plus x-default pointing at the English URL — and nothing else. Mixing fallback logic into it would mean the hreflang graph changed shape depending on how far along a translator was, which is a fine way to teach a search engine that your URL structure is unstable. Coverage is expressed where coverage belongs: the sitemap restricts hreflang alternates for work and journal detail pages to the locales that actually have a published row.

Localized slugs also have a consequence at the crawler boundary that nobody predicts the first time. The quote flow is disallowed in robots.txt — it is a funnel, not a landing page — and because the slug is localized, there is no single path to disallow. There are six. Add that robots.txt groups are independent of one another (a disallow written in one group does not apply to the next), and that we allowlist eighteen AI crawlers in eighteen separate groups, and the disallow list has to be repeated in full, every time, by every group. It is generated, not typed, for reasons that should be obvious.

The multiplication shows up in the sitemap too. Twenty static pages become 120 entries before a single game, case study or journal piece is counted, and each of those entries carries its own alternates block. A sitemap of that shape is not something anyone maintains by hand; it is a function of the pathnames map, which is the same reason the pathnames map is allowed to be the only place a slug is written down. Anything that has to be repeated in six places will eventually be repeated in five.

The last mile is social metadata, where six locales become an Open Graph map — en_US, ru_RU, es_ES, de_DE, fr_FR, pt_PT — emitted by a shared helper that always re-states every field. That last part is not stylistic: Next.js replaces rather than deep-merges openGraph per route segment, so any page that sets one of its own fields silently drops the locale layout's defaults, taking og:site_name and og:locale with it. The helper exists so that no one has to remember this.

None of the above is exotic. What makes it hold together is that each of the six locales is treated as a first-class surface with the same guarantees — its own URL, its own catalog, its own metadata, its own crawler story — rather than as English with a lookup table in front of it. The measure of that isn't the launch; it's the cost of the twenty-ninth pathname entry, which is one line in the map, six slugs, one sitemap row, and no argument. We wrote about why we hold that bar in The Conjuring Standard; this is what it looks like when the bar is a routing table.