Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 11 of 12IntermediateAug 3, 2026

Internationalization Architecture

Structuring translation content, locale-aware routing and its SEO implications, shipping only the active language pack, and treating RTL as architecture.

frontend-system-designi18nseo

Why It Matters

Internationalization is treated as a translation task and is actually a set of architecture decisions. Whether you can add a language without a rebuild, whether users download translations they will never read, whether search engines index your locales correctly, and whether an RTL layout takes a week or a quarter - all of that is determined by structure, long before any translator is involved.

Retrofitting i18n is one of the most expensive things a frontend team can be asked to do, because the assumptions it violates - that text is a literal, that layout flows left to right, that a plural means "not one" - are spread across every file.

Structuring Translation Content

Key-based, not inline

Inline translation puts source text in the component and looks up a translation by that text. It reads well - the component shows what it says - and it fails structurally. The key is the English copy, so fixing a typo silently orphans every translation of that string. Two identical English strings needing different translations in another language cannot be distinguished. And translators receive strings with no indication of where they appear.

Key-based translation stores a stable identifier and looks it up per locale:

{
  "checkout.payment.submitButton": "Place order",
  "checkout.payment.errorDeclined": "Your card was declined"
}

The key is stable, so English copy can change without touching any other locale. Namespacing by feature makes it possible to split translation files along the same boundaries as your code. And a key that no longer appears in the source is mechanically detectable, which is the only way translation files avoid accumulating dead entries indefinitely.

The cost is that reading a component no longer tells you what it says. That is a tooling problem - editor plugins and inline previews - not a reason to give up the stability.

Where the values live

Two rules that decide whether translations stay maintainable:

No string concatenation. t('greeting') + userName + t('welcome') assumes English word order and cannot be translated into a language that orders those parts differently. Use interpolation inside a single message - t('greeting', { name }) - so the translator controls the whole sentence.

Context belongs in the file. Translators see strings without the UI. A key called common.close might be a verb on a button or an adjective in a status. Description metadata alongside the value is what prevents that guess from being wrong.

Locale-Aware Routing

The URL structure decision has consequences that are mostly invisible until they are expensive.

Diagram
100%
flowchart TB subgraph Sub["SUBDOMAIN - fr.example.com"] S1["Clean separation per locale"] S2["Can host in different regions"] S3["Cookies, CSP, SW scope duplicated"] S4["Domain authority split across origins"] S5["New locale = DNS + TLS change"] end subgraph Path["PATH PREFIX - example.com/fr/ - recommended"] P1["One origin - one cert, one SW, one CSP"] P2["Authority consolidated"] P3["Each path is a distinct indexable URL"] P4["New locale = routing change only"] P5["Root path needs a default-locale decision"] end subgraph Query["QUERY PARAM - example.com?lang=fr - avoid"] Q1["Trivial to bolt on"] Q2["Crawlers may treat it as the same page"] Q3["Weak or no independent ranking"] Q4["Easy to lose on navigation"] end style Path fill:#1e3f2d,stroke:#22c55e style Sub fill:#3f2d1e,stroke:#f59e0b style Query fill:#3f1e1e,stroke:#ef4444
visualized byIOCombats

Path prefix is the right default. One origin means cookies, service worker scope, CSP, and TLS are configured once. Domain authority consolidates rather than splitting. Each prefixed path is a distinct URL a crawler will index. And adding a locale is a routing change rather than a DNS and certificate change.

Subdomain earns its keep when locales are genuinely separate operations - different content, different teams, sometimes different infrastructure regions. The cost is that every origin-scoped concern is now configured per locale, and authority is divided.

Query parameter should be avoided. Crawlers frequently treat ?lang=fr as the same page with a parameter rather than a distinct document, so the localized version may never rank independently. It is also easy to drop on navigation.

Getting SEO right

Three things must be true, and each is commonly wrong:

hreflang annotations must be reciprocal. Every page in a locale set must list every other, including itself. A one-directional annotation is typically ignored entirely - so you get the duplicate-content problem you believed you had solved.

x-default must be present. It tells the crawler where to send users whose language matches none of your locales.

Never auto-redirect based on Accept-Language. A crawler indexing from a US data centre gets redirected to English and never sees the other locales at all. Detect, suggest with a dismissible banner, and remember the choice - but let the URL be authoritative.

Bundling Strategy

Shipping every locale to every user is the default failure. Twenty locales at 40KB each is 800KB, of which 760KB is guaranteed waste.

The fix is to treat language packs as dynamic imports keyed by locale:

const messages = await import(`./locales/${locale}/common.json`);

The bundler emits one chunk per locale, and only the active one is fetched.

Two refinements matter at scale:

Split by route as well as locale. A user on checkout should not download marketing copy. The natural key is locale plus route, which maps directly onto the code splitting boundaries you already have.

Split the formatting data too. A full ICU locale dataset is substantial, and there is no reason to ship data for locales the user does not have.

The timing detail that decides whether this feels fast: resolve the locale as early as possible, ideally server-side from the URL. If the locale is only known after the app boots, the language pack request starts late and the user watches untranslated keys or a blank screen. Resolved server-side, the correct pack can be preloaded in parallel with the application bundle.

The Concerns That Are Architecture, Not Translation

Pluralization

Plural categories are a property of the language, not a universal binary. English has two, Japanese has one, Russian has three with rules based on the last digits, Arabic has six.

// Bakes English grammar into rendering logic.
count === 1 ? '1 item' : `${count} items`;

Every language that does not match that shape becomes wrong, and translators cannot fix it without a code change.

The ICU message format moves the grammar into the message, where translators own it:

{count, plural,
  =0 {No items}
  one {# item}
  other {# items}
}

The runtime selects the category using CLDR data for the locale. A translator working in Russian supplies one, few, and many in their file; nothing in your code changes.

Dates, numbers, and currency

Use Intl.DateTimeFormat, Intl.NumberFormat, and Intl.RelativeTimeFormat. They are built into the platform and encode rules you should not be reimplementing - decimal separators, digit grouping, calendar systems, currency placement.

Two things they do not solve. Time zones are separate from locale - a user in Tokyo may want English with JST, and conflating the two is a persistent source of off-by-one-day bugs. And currency is a business decision, not a formatting one: formatting a price in EUR does not convert it, and showing a converted price implies a rate and a commitment.

RTL layout

Arabic, Hebrew, Persian, and Urdu flow right to left, and the mirroring is not limited to text - the whole layout inverts. Navigation, icons, progress indicators, and the reading order of columns all flip.

The architectural fix is in your styling primitives, not in each component. Logical properties describe position relative to the writing direction rather than the screen:

  • margin-inline-start instead of margin-left
  • padding-inline-end instead of padding-right
  • inset-inline-start instead of left
  • text-align: start instead of text-align: left

Set dir="rtl" on the document and a layout built on logical properties mirrors automatically. Built on left and right, every component must be audited and mirrored individually - and every new component reintroduces the problem.

Diagram
100%
flowchart TB R["Request arrives"] --> D{"Locale in URL path?"} D -->|"yes - /fr/checkout"| Set["locale = fr"] D -->|"no - bare /checkout"| AL["Read Accept-Language"] AL --> Match{"Supported?"} Match -->|"yes"| Redirect["Redirect once to /fr/checkout<br/>URL stays authoritative"] Match -->|"no"| Def["locale = x-default"] Redirect --> Set Def --> Set Set --> Parallel["Server resolves locale before render"] Parallel --> LP["Preload language pack<br/>locale + route chunk"] Parallel --> ICU["Preload ICU data<br/>this locale only"] Parallel --> Dir["Set dir attribute<br/>rtl or ltr"] LP --> Render["Render translated HTML"] ICU --> Render Dir --> Render Render --> SEO["Emit reciprocal hreflang set<br/>+ x-default"] style Parallel fill:#1e3a5f,stroke:#3b82f6 style Render fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats

Tradeoffs

OptionProsConsWhen to Use
Key-based translationStable keys, copy edits do not orphan translations, unused keys detectableComponent no longer shows its own text, needs toolingAny product with more than one locale
Inline translationReads naturally, no indirectionCopy edits break translations, cannot disambiguate identical stringsPrototypes only
Path prefix routingOne origin, consolidated authority, distinct indexable URLs, cheap to add localesRoot path needs a default-locale decisionThe default for almost every product
Subdomain routingClean separation, regional hosting, independent operationAuthority split, per-origin config duplicated, DNS and TLS per localeGenuinely separate regional businesses
Query parameter routingTrivial to addCrawlers may not index locales independently, easy to loseAvoid
Per-locale dynamic importsUsers download only their language, scales to many localesLocale must resolve early or the pack loads lateAny product past two or three locales
ICU message formatGrammar lives with translators, correct for every plural systemMessage syntax to learn, larger runtimeAnywhere counts or gendered strings appear in copy
Logical properties for RTLLayout mirrors automatically, correctness is the defaultNeeds discipline and lint enforcement to stay consistentFrom day one if RTL is remotely plausible

Where This Applies

Resolving the locale server-side so the language pack loads in parallel is a rendering decision - see Rendering Architecture. Per-locale chunking uses the same splitting mechanics described in Performance Engineering, and font loading interacts with CLS differently across scripts. RTL mirroring is closely related to the primitive-layer argument in Accessibility Architecture: both are cases where correctness belongs in shared components rather than in each consumer.

In the applied practice problems, this shapes Date Picker most of all, where week start, field order, calendar system, and the difference between a calendar date and an instant are each a distinct source of silent bugs, and Shopping Cart, where currency formatting and minor-unit arithmetic decide whether a total is right.

Advertisement

Frequently Asked Questions

Why is a path prefix usually the right URL structure for locales, and when is a subdomain better?

A path prefix keeps every locale on one origin, so domain authority, cookies, service worker scope, and CSP all apply once rather than per locale, and adding a language is a routing change rather than a DNS and certificate change. Search engines treat each prefixed path as a distinct indexable URL, which is what you need. A subdomain is better when locales are genuinely separate operations - different content, different teams, sometimes different infrastructure - because it gives you clean separation and lets you host them in different regions. The option to avoid is a query parameter, since crawlers often treat it as the same page with a parameter rather than a distinct one.

What does hreflang do, and what breaks if you get it wrong?

It tells search engines that several URLs are the same content in different languages, so the crawler serves the right one to the right user instead of treating them as duplicates competing with each other. What breaks is that the annotations must be reciprocal - every page in the set must list every other, including itself. A one-directional annotation is typically ignored entirely, so you get the duplicate-content problem you were trying to avoid while believing it is handled. The other common mistake is omitting x-default, which is what tells the crawler where to send users whose language matches none of your locales.

How do you avoid shipping every locale's translations to every user?

Treat language packs as dynamic imports keyed by locale, so the bundler emits one chunk per locale and only the active one is fetched. Beyond that, split by route as well as by locale, so a user on the checkout page does not download the marketing copy - the natural key is locale plus route, matching your existing code splitting boundaries. The formatting libraries need the same treatment, since a full ICU locale dataset is large and there is no reason to ship data for locales the user does not have. Resolve the locale as early as possible, ideally server-side from the URL, so the correct pack loads in parallel with the app rather than after it.

Why is pluralization not just a matter of checking whether a count is one?

Because plural categories are a property of the language, not a universal binary. English has two, Arabic has six, Russian has three with rules based on the last digits of the number, and Japanese has one. Hardcoding an if-count-is-one check bakes English grammar into your rendering logic, and every language that does not match it becomes wrong in a way translators cannot fix without changing code. The architectural answer is the ICU message format, where the message itself declares its plural categories and the runtime picks the right one using CLDR data for the locale - which keeps grammar in the translation file where translators own it.

What makes RTL an architectural concern rather than a styling task?

Because the fix has to happen in your styling primitives, not in each component. If components use left and right directly, supporting RTL means auditing and mirroring every one of them, and every new component reintroduces the problem. If they use logical properties - inline-start and inline-end rather than left and right - the layout mirrors automatically when the document direction changes, and correctness becomes the default rather than something each author must remember. The parts that genuinely need per-case decisions are icons and imagery, since directional icons like a back arrow must mirror while logos and media playback controls must not.