cf.completefrontend
My progress
SYSTEM DESIGN · #68

Design an internationalization system

The i18n platform behind a product that ships in 30+ languages: the API app developers write against, the pipeline that carries strings to translators and back without blocking releases, and the runtime that loads only the right catalog and renders plurals, genders, dates and right-to-left text correctly. The hard part is the system around the strings: ids that survive edits, a message format translators can work with, catalogs that stay small, and checks that stop a broken translation before users see it.

The question

Our travel booking web app is growing abroad. Product wants it in 32 languages by the end of the year, including Arabic, Hebrew, Japanese, Russian and Polish, and more after that. Design the internationalization system: what app developers use to write translatable UI, how strings get to translators and back, and how the app loads and renders the right language.

We have about 14 frontend teams in one React and Next.js codebase, around 6,000 UI strings today, and we deploy to production many times a day. We can't stop shipping while translations catch up.

Real-life examples: Airbnb in 60+ languages, Shopify admin and checkout, Mozilla Firefox with Fluent, Slack and Notion language settings, Crowdin, Lokalise, Phrase and Smartling

Requirements: what an i18n platform really has to do

Internationalization, shortened to i18n (18 letters between the i and the n), is the engineering work that makes a product able to run in any language and region. Localization (l10n) is the per-language work done on top of it: translating the strings, picking the right date format, adapting images. A locale is a language plus optional region and script, written as a tag like en-GB, pt-BR or zh-Hant-TW. This question asks for the i18n system: the platform that makes localization cheap, fast and safe for every team.

The naive version is a JSON file per language and a t('key') function. It works for two languages and one team.

At 32 languages and 14 teams it falls apart in predictable ways: English strings ship in German for weeks because nobody exported them, translators guess what "Book" means (a noun or a verb?), a Polish plural reads like broken grammar, the Arabic page lays out left to right, the bundle carries all 32 languages, and one translator's typo in a placeholder crashes checkout. A strong answer is mostly about those failures and the system that prevents them.

The published lessons teach the building blocks: translation-architecture covers catalogs and ICU syntax, locale-formatting covers the Intl APIs, rtl-layouts covers logical CSS properties, and i18n-performance-seo covers hreflang and catalog loading. This guide designs the whole system around them and weighs the trade-offs between its parts.

Questions to ask

Each answer below changes a real part of the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Which languages, and any right-to-left ones?32 locales at launch, including Arabic and Hebrew. Some regional variants: en-GB, pt-BR, pt-PT, es-419, zh-Hans, zh-Hant.RTL means layout and icon mirroring, not only text. Variants mean fallback chains.
Who translates?A vendor through a translation management system (TMS), plus in-country reviewers for the top 8 markets.We need an automated pipeline to a TMS and a review step, not files mailed around.
Can a release wait for translations?No. Code ships many times a day; translations arrive in 1 to 5 days.Untranslated strings must render something sensible: a fallback chain, never a raw key.
How is the app rendered?Next.js with server rendering for public pages (search, hotel pages) and client navigation inside the booking flow.The server must pick the locale before it renders, and the client must be able to switch without a reload.
Do public pages need search ranking per language?Yes, hotel pages are our biggest traffic source.Each language needs its own URL. Locale can't live only in a cookie.
Is there user-generated content?Yes: hotel reviews and host messages, in whatever language the author wrote.UI strings and user content follow different rules: we translate one, we label the other.

Functional requirements

  • Developers write UI text once, in English, with a message id and a description for translators. No hand-edited translation files.
  • Messages support variables, plurals, select by gender or type, and rich text such as a link inside a sentence.
  • New and changed strings reach the TMS automatically on merge; finished translations come back automatically as a pull request or a published bundle.
  • The app renders in the locale from the URL, remembers the user's choice, and can switch language without a full page reload.
  • Dates, numbers, prices, relative times and lists are formatted for the locale, and times are shown in the user's time zone.
  • Arabic and Hebrew render right to left, including layout, icons and mixed-direction text.
  • Missing translations fall back through a chain such as pt-PT then pt then en, and get reported.

Non-functional requirements

  • Bundle size: the user downloads only their locale and only the strings of the screens they open. Budget: under 15 KB compressed on the first page for translation data plus any message parser.
  • Switch speed: changing language takes under 300 ms on a fast connection, with no flash of the wrong language and no layout jump.
  • Freshness: a translation fix can reach production in under an hour, without waiting for an app deploy.
  • Safety: a bad translation can't break the page, drop a variable, or inject HTML.
  • Developer experience: adding a string is one line of code; the build fails on obvious mistakes like a hard-coded English sentence.
  • Scale: 6,000 strings today, 20,000 later, times 32 locales, without slowing the build or the TMS sync.

Out of scope

We leave out translating marketing pages and CMS content (the admin-cms guide covers content localization), localizing emails and push notifications on the server (same pipeline, different renderer), and currency conversion (a pricing problem, not a display one). We do cover how user-generated content is labelled and optionally machine translated, because it shares the page with UI strings and gets confused with them.

Check your understanding · Exercise 1

Which answers from the interviewer would change the architecture, not only the amount of work? Select all that apply.

What makes this hard

Rendering a string from a dictionary is easy. Four properties of real languages, real teams and real releases make the system hard, and every later chapter answers one of them.

  • Grammar is not English grammar. English has two plural forms for whole numbers (1 item, 2 items). Japanese has one. Russian and Polish have four categories. Arabic has six: zero, one, two, few, many and other. Many languages change words by gender, and word order differs. Any design that glues sentence pieces together in code breaks the moment a translator needs a different order.
  • Two clocks run at different speeds. Code changes ship in minutes. Translations arrive in days. The system must ship code with missing translations and fill them in later, so every string is in one of several states at any time: new, in translation, in review, done, or stale because the English changed.
  • Text is the biggest repeated payload. 6,000 strings at about 60 bytes each is roughly 360 KB per locale before compression, and 32 locales is over 11 MB. Users need one locale and only the screens they visit. Loading has to be split by locale and by route, and cached well.
  • The text is written by people who never see the code. Translators work in a TMS, often without seeing the screen. They need context (what is this, where does it appear, how long can it be), and their output must be checked by machines before it reaches users, because one dropped {count} or a stray tag can break a page.

There are also layout effects. Translated text is usually longer than English: IBM's guidelines, quoted by the W3C, expect long passages to grow by about 30 percent in European languages and strings of up to 10 characters to double or triple. German and Finnish add long compound words that don't wrap.

Right-to-left scripts flip the whole page. Chinese and Japanese put no spaces between words, and CJK characters (Chinese, Japanese, Korean) are wider than Latin letters, so line breaking and truncation behave differently. Buttons, tabs and table headers built for English widths overflow or wrap.

The pay button's label in six locales, drawn against a dashed box the width of the English button. English, Pay now, fits. German, Jetzt bezahlen, French, Payer maintenant, and Russian, Оплатить сейчас, are about twice as long and overflow. Japanese is short in characters but each one is wide, so it only just overflows. Arabic fits and reads right to left.English widthenPay nowdeJetzt bezahlenfrPayer maintenantruОплатить сейчасja今すぐ支払うarادفع الآنde, fr, ru: about2x the English;short ones grow mostja: fewer chars,wider glyphs
A button sized for 7 English characters overflows in four of these six locales. Design for the longest language, let buttons grow, and test with an expanded pseudo-locale before translations exist.

Keep the four properties in mind as the axes of the design: a message format that encodes grammar, a pipeline that tolerates two clocks, a loader that ships the minimum text, and checks that guard the translator-to-user path. The rest of the guide takes them one at a time.

Messages: choosing a format that encodes grammar

Start with the inbox badge on the trips page. The naive code builds the sentence from parts: 'You have ' + n + ' new ' + (n === 1 ? 'message' : 'messages'). It reads fine in English. Now send it to a translator: they get You have, new, message and messages as four separate strings, with no idea they form one sentence.

Russian needs three noun forms (1 сообщение, 2 сообщения, 5 сообщений, and 21 goes back to сообщение). Japanese needs none but puts the number in a different place. Arabic has six plural categories, and German moves the verb. The code's n === 1 check is English grammar baked into JavaScript, and no translator can fix it.

The rule that follows: a message is a whole sentence, with named placeholders and all its grammatical variants inside it, and the translator owns the whole thing. Code passes values in; the message decides the words and the order. That needs a message format with plural and select built in. There are four serious choices in 2026.

Decision Which message syntax should the platform standardize on?

ICU MessageFormat (FormatJS, react-intl, Lingui)

Messages like {count, plural, one {# night} other {# nights}} and {gender, select, female {...} other {...}}, with nesting.

Plural categories come from CLDR (the Unicode locale data) through Intl.PluralRules, so each language uses its own categories.

Pros

  • The de facto industry format: major TMS products such as Crowdin, Lokalise, Phrase and Smartling support it, and their editors can check the syntax and show translators the plural forms their language needs.
  • Can be compiled to an AST (abstract syntax tree) at build time, so the browser doesn't ship the parser.
  • ICU libraries implement the same syntax for Java and Android (built in since API level 24), and TMS tools can export the same translations to iOS formats, so web and mobile can share messages.

Cons

  • Nested plurals inside selects get hard to read.
  • Apostrophe escaping ('{') trips people up.
  • The classic syntax gets no new features; its successor, MessageFormat 2, uses a new syntax.

i18next JSON format

Interpolation with {{name}}, plurals as separate keys with suffixes (cart_one, cart_other, cart_few...), context as _male or _female suffixes.

Plural suffixes are chosen with Intl.PluralRules since i18next v21.

Pros

  • Very popular, huge plugin ecosystem, works in any framework.
  • Flat key-value JSON is easy for any tool to read.

Cons

  • A sentence's variants are spread over several keys, so translators and tools must keep them in sync.
  • Combining plural and gender in one sentence needs extra keys or the ICU plugin.
  • Runtime interpolation means the formatter ships in the bundle.

Project Fluent (Mozilla)

.ftl files with selectors like { $count -> [one] ... *[other] ... }, terms such as -brand-name, and attributes.

Used to translate Firefox.

Pros

  • Asymmetric localization: a translation can add variants English doesn't have, like grammatical case, without code changes.
  • Designed for translators first; very readable.

Cons

  • Smaller ecosystem; fewer commercial TMS tools support it fully.
  • Few hiring-market engineers know it; mobile apps would need a separate format.

Unicode MessageFormat 2 (MF2)

The new Unicode standard, declared stable in CLDR 47 (March 2025): .input {$count :number} then .match $count with variants.

A native Intl.MessageFormat is a TC39 proposal, still at Stage 1 and not in browsers.

Pros

  • Designed as the long-term standard, with a data model that older formats can be converted into.
  • Clearer syntax for multiple selectors and custom functions.

Cons

  • Libraries and TMS support are still early in 2026.
  • Without a native API you ship a polyfill-sized library, the opposite of our bundle goal.

What we pick: ICU MessageFormat through FormatJS. It's what every TMS on our shortlist supports and previews for translators, it compiles at build time so the parser stays out of the bundle, and the same translations can feed our iOS and Android apps. We keep the door open to MF2: messages are stored in the TMS and our repository as ICU strings plus metadata, and MF2's data model can represent them, so a later migration is a conversion script, not a rewrite.

ICU messages in practice

Here is one real message from the booking summary, with a gender select around a plural, plus rich text. Notice that each branch is a complete sentence. Splitting {host} has from # guests would bring back the concatenation problem inside the message itself.

ts
defineMessages({
  hostSummary: {
    id: 'booking.summary.host',
    description: 'Under the booking total. host = host first name, hostGender from profile, guests = guest count.',
    defaultMessage: `{hostGender, select,
      female {{guests, plural,
        one {{host} is expecting # guest. <link>Message her</link>}
        other {{host} is expecting # guests. <link>Message her</link>}}}
      male {{guests, plural,
        one {{host} is expecting # guest. <link>Message him</link>}
        other {{host} is expecting # guests. <link>Message him</link>}}}
      other {{guests, plural,
        one {{host} is expecting # guest. <link>Message them</link>}
        other {{host} is expecting # guests. <link>Message them</link>}}}
    }`,
  },
});
Select outside, plural inside, whole sentences in every branch. # prints the number formatted for the locale; <link> is a tag the code maps to a component.
  • other is required in every plural and select. It's the safety branch when a value doesn't match, and some languages use only other.
  • Exact matches like =0 {No guests yet} override categories. Use them for the zero case in English instead of an if in code; a translator can still choose to use them or not.
  • selectordinal handles 1st, 2nd, 3rd ("your 2nd stay"), which has its own rules per language.
  • Don't use plural for anything but numbers. A select on {status} is the right tool for enums like confirmed, pending and cancelled.
  • Numbers and dates go through the formatter, not into the string pre-formatted: {total, number, ::currency/EUR} or {checkIn, date, medium}. That keeps digits, separators and month names in the user's locale.

Try it below. The lab renders one plural message for English, Arabic, Russian and Japanese and shows which CLDR category each count lands in. Edit the translations and break them: drop the {hotel} placeholder, remove the other branch, delete Russian's few branch, or add a few branch to Japanese, which has only other. The validator reports what a CI check would.

Locale

Casa Azul: осталось 3 комнаты

Plural categories for ru: one, few, many, other. Count 3 is "few". Sample counts: 0 is many, 1 is one, 2 is few, 3 is few, 5 is many, 11 is many, 21 is one, 100 is many. 0 errors and 0 warnings.count -> CLDR category in ru0many1one2few3few5many11many21one100manyru: one, few, many, othercount 3 is fewerrors: 0warnings: 0
Valid. ru has 4 plural categories, and the message covers each one.

Check your understanding · Exercise 2

A developer writes t('nights.prefix') + ' ' + n + ' ' + (n === 1 ? t('night') : t('nights')). What goes wrong when this ships in 32 languages? Select all that apply.

Ids: stable keys or the English text?

Every message needs an id: the name that links the English in code to its 31 translations. It sounds like a naming detail. It decides what happens every time someone edits English, which on a 14-team codebase is dozens of times a week.

The naive setup is a hand-edited en.json with keys like payButton, and code calling t('payButton'). Within a year it has 900 keys nobody dares delete, three keys with the same English, and keys whose names no longer describe the text. Developers must open a second file to know what a key says, and translators see payButton with no description.

Decision What should identify a message?

Semantic key, English in a separate file

Code calls t('checkout.button.pay'); en.json maps it to Pay now.

Translators get the key and the English.

Pros

  • Ids never change when English changes, so translations stay attached.
  • Keys can carry structure (feature, screen, element) that helps ownership.

Cons

  • Two places to edit; English drifts from what the code means.
  • Dead keys pile up because nothing links a key to its use.
  • Easy to reuse a key in a context where its translation is wrong.

The English text is the id (gettext style)

Code calls t('Pay now'); the catalog maps Pay now to each translation.

Lingui's and gettext's classic style.

Pros

  • Readable code with no key invention; missing translations fall back to readable English for free.
  • No separate English file.

Cons

  • Any English edit, even a typo fix, orphans every translation of it.
  • Book the verb and Book the noun collide unless you add a context field.
  • Long sentences make awkward keys.

Hash of the English plus description

The build computes an id such as a 6-character content hash from defaultMessage and description (FormatJS idInterpolationPattern, Lingui's generated ids).

Code holds only the English and the description.

Pros

  • No naming; same text in different contexts gets different ids because the description differs.
  • Short ids keep catalogs small.

Cons

  • Same orphaning problem as source-as-id: edit one character and 31 translations detach.
  • Ids are opaque in logs and missing-key reports.

Semantic key with the English colocated in code

Code declares id, defaultMessage and description together, as in the snippet in the last chapter.

An extractor reads them at build time; no hand-edited English file exists.

Pros

  • Readable code and stable ids; the English lives next to the component that uses it.
  • Extraction finds dead and duplicate ids automatically.
  • The developer decides whether an edit keeps the id (typo) or gets a new one (new meaning).

Cons

  • Developers must invent ids, so we need a naming convention and a lint rule.
  • Slightly more typing than a plain t('Pay now').

What we pick: Semantic keys with the English colocated in code. Stable ids matter most at our scale: a typo fix in a button used on 40 screens shouldn't throw 31 languages back to English for three days. The extractor gives us what source-as-id promised (one place to edit, readable fallbacks) and also tells us when English changed under an existing id, so the TMS can flag the translations for review instead of deleting them.

When the English changes

The extractor stores a fingerprint, a hash of defaultMessage, next to each id. On the next sync it compares fingerprints with the TMS: same id with a new fingerprint means the English changed. The TMS keeps the old translations but marks them as needing review, and the runtime keeps serving them meanwhile. A slightly outdated German sentence is better than an English one.

If the meaning changed, the old German is now wrong, not just old: Free cancellation became Free cancellation until 48 hours before check-in. That's a new message, so the developer gives it a new id, and German falls back to English until translated. A lint rule asks the question in review: if defaultMessage changed by more than a few characters and the id didn't, the pull request gets a warning.

The English heading changes from Cancelation policy to Cancellation policy. Left, with a semantic id, checkout.policy.title stays the same: the German, French and Arabic translations are kept and flagged for review, and users keep seeing their language. Right, with a hashed id, the id changes from a3f9c1 to 7be210: all three translations are orphaned and users see English in 31 locales until they are redone.English edit: Cancelation to Cancellationsemantic idcheckout.policy.titleid unchangeddefrarkept, flagged for reviewusers keep their languagehashed ida3f9c1 becomes 7be210new id, nothing attacheddefrartranslations orphanedEnglish in 31 locales
With semantic ids a typo fix is a review task. With ids derived from the text, every edit detaches every translation, so the fix ships as English everywhere.
  • Naming convention: area.screen.element[.variant], for example checkout.summary.payButton. The first segment maps to a namespace file and to an owning team.
  • Duplicates: the same id with two different defaultMessage values fails the build. Two ids with the same English are allowed: they may need different translations.
  • Dead ids: ids in the TMS but not in the latest extraction are marked obsolete and deleted after 30 days, so older deployed versions still find their strings during a rollback.
  • Descriptions are mandatory: a lint rule rejects a message without one. It's the cheapest context a translator gets.

Check your understanding · Exercise 3

A developer fixes Cancelation policy to Cancellation policy in a heading. What should happen under our design? Select all that apply.

The string lifecycle: from code to translators and back

A string's life starts in a pull request and ends, days later, as 31 translations in users' browsers. The naive pipeline is manual: once per sprint someone exports en.json, uploads it to the vendor, waits, downloads 31 files and commits them. Each round takes a week, the export misses strings merged after it, two teams' commits conflict on the same JSON files, and features launch half-English because their strings missed the cutoff. With many deploys a day, any manual step becomes the bottleneck.

Continuous localization fixes this: every merge sends new strings to the TMS automatically, translators work all the time, and finished translations flow back automatically. The design question is where translations live and how they get to users.

Decision How should finished translations reach production?

Committed to the repo, shipped with the app

The TMS's Git integration opens a pull request with updated locale files; it merges and ships with the next deploy.

Pros

  • Every build is reproducible: code and strings are versioned together.
  • Simple to set up; most TMS tools offer it.

Cons

  • A translation fix waits for an app deploy.
  • Bot pull requests every few hours add noise and merge conflicts.
  • The repo grows with 31 copies of every string.

Fetched from the TMS at runtime (over-the-air)

The app downloads catalogs from the vendor's CDN when it starts, as offered by several TMS products.

Pros

  • Fixes go live in minutes without a deploy.
  • No translation files in the repo.

Cons

  • Production depends on a vendor's uptime and CDN.
  • No validation step of our own between a translator's keyboard and users.
  • Hard to roll back with the app or to test exact pairs of code and strings.

Our own publish step to our CDN

CI pushes source strings to the TMS API on merge; a webhook on approval triggers a publish job.

The job validates, merges fallbacks, compiles, uploads content-hashed files, and updates a small manifest.

Pros

  • Fixes go live in minutes, through our checks, on our CDN.
  • Each publish is versioned, so rollback is pointing the manifest back.
  • The app build pins a baseline manifest, so a fresh deploy never has missing files.

Cons

  • We build and run the publish job.
  • Code and catalogs move separately, so their contract (ids and placeholders) needs rules.

What we pick: Our own publish step. It meets the under-an-hour freshness goal without trusting a vendor CDN in the critical path, and it puts validation where it belongs: after the translator and before the user. The cost is a contract between code and catalogs, which we keep with two rules: an id never changes its placeholders (a new placeholder means a new id), and obsolete ids live 30 days so rolled-back code still finds them.

How a string flows end to end. A pull request with defineMessages goes to the extractor on merge. The extractor sends a source diff with context to the TMS. When translators approve, a webhook sends approved text to the publish job, which rejects bad strings and keeps the old one. The publish job uploads hashed JSON to the catalog CDN. The server reads the manifest and renders HTML with the first page's messages for the browser. The browser later prefetches the next route's namespaces from the CDN.Pull requestdefineMessagesExtractorfingerprintsTMScontext, memorymergediffPublish jobvalidate, mergeCatalog CDNimmutable filesServer renderreads manifestapprovedhashedURLsBrowserlang, dir setHTML + msgsprefetch namespacesbad string:old one stays
Code and translations travel separately and meet on the CDN. Only the small manifest changes on each publish; every catalog file such as de/checkout.9c1e2a.json is immutable.

Giving translators context

Most bad translations are good translations of the wrong meaning. Book becomes the noun, Free becomes "without cost" when it meant "available", and a 9-character button gets a 26-character label. The pipeline attaches context to every string it uploads:

  • Description from the code, required by lint: "Button on the payment step. Charges the card."
  • Placeholder notes: what {host} and {guests} are, with example values.
  • Max length for tight spots like tabs and buttons, which the TMS enforces as a warning.
  • Screenshots: our end-to-end tests run once in a special pseudo-locale that wraps each string with its id; a script finds those markers on screen and uploads cropped screenshots to the TMS, tagged with the ids they contain.
  • Glossary and do-not-translate terms: the brand name, "Superhost", product tiers. The TMS warns when a translation breaks them.
  • Translation memory: the TMS suggests earlier translations of the same or similar text, which keeps wording consistent and cuts cost.

Each string then moves through states. Vendors translate, in-country reviewers approve for the top 8 markets, and the publish job only ships approved text. Edits to English send a string back to review, and machine translation can fill gaps for low-traffic locales, clearly marked, until a human gets to them.

A state machine for one string in one locale. New goes to Translating when sent to a vendor. Translating goes to Review when done, and Review can reject back to Translating. Review goes down to Approved when approved. Approved goes to Stale when the English is edited, and Stale goes back to Review for a re-review. A string whose id is removed from the code becomes Obsolete, kept 30 days. Only Approved strings are published.Newfrom extractorTranslatingvendor or MTReviewin-countrysenddonerejectApprovedpublishedStaleold text servedObsoletekept 30 daysapproveEnglish editre-reviewid removedpublish shipsApproved only
Only Approved text ships. A Stale string keeps serving its old translation while it waits for review, because slightly old German beats English.

Fallback chains for missing strings

At any moment some strings are untranslated, so every lookup needs a chain. For pt-PT the chain is pt-PT, then pt, then en. For en-GB it's en-GB then en: a British user should see "colour" where we have it and plain American English elsewhere, not a raw id.

Chains aren't always the obvious prefix. zh-Hant-HK must fall back to zh-Hant (Traditional characters), never to zh that defaults to Simplified, and es-AR goes through es-419 (Latin American Spanish). CLDR publishes these parent locales, and we generate chains from it rather than chopping strings at the hyphen.

Where do we walk the chain? At runtime, the client would load up to three files per namespace and check each lookup three times. Instead the publish job merges the chain into one file per locale per namespace, and adds a list of the ids that came from a fallback. The client loads one file and does one lookup, and the list feeds the missing-translation dashboard.

Check your understanding · Exercise 4

Translators approve a German fix at 14:00. Under our design, which statements are true? Select all that apply.

Bundling and loading: ship one locale, one screen at a time

Do the arithmetic first. 6,000 strings at about 60 bytes is roughly 360 KB of JSON per locale, which compresses to somewhere near 80 to 100 KB with Brotli. Times 32 locales, that's 3 MB compressed.

The search page uses about 250 of those 6,000 strings. So almost everything a naive setup downloads is waste: other languages, and screens the user never opens.

The naive setup imports every locale file into the JavaScript bundle so t() is synchronous. It's simple and fast to switch, and it puts megabytes on the critical path of every first visit. The opposite naive setup fetches one file for the active locale on startup, then renders. That's better, but still about 90 KB for a page that needs under 10 KB, and it adds a request before any text can show.

Decision How should catalogs be split and loaded?

Everything in the bundle

All locales imported as modules; the bundler includes them in the main chunk.

Pros

  • Zero requests, instant locale switch, trivial code.

Cons

  • Megabytes of text for every user; grows with every new language.
  • A typo fix needs an app deploy.

One catalog per locale

Fetch /i18n/de.json for the active locale only, before or during first render.

Pros

  • Users only pay for their language; one cacheable file.

Cons

  • Still loads every screen's strings, about 90 KB here and growing.
  • Any change to any string invalidates the whole file in every user's cache.

Per locale and per namespace

Split by the id's first segment: common, search, hotel, checkout, account.

Each route declares its namespaces; they load in parallel with the route's code chunk.

Pros

  • The first page loads common plus one or two namespaces, about 7 to 11 KB of strings.
  • A change to checkout strings doesn't evict the cached search strings.

Cons

  • More files and a route-to-namespace map to maintain.
  • Client navigation must prefetch namespaces, or text appears late.
  • The ICU parser, about 9 KB compressed, still ships to parse the strings.

Per namespace, precompiled

Same split, but the publish job stores each message as a parsed AST instead of an ICU string.

The parser is aliased away from the client bundle.

Pros

  • No parsing work on the device and no parser code in the bundle.
  • Malformed messages fail at publish time, never in the browser.

Cons

  • AST JSON is usually larger than the source string, so the catalog grows a little.
  • Every message must be precompiled, including the English defaultMessage in code; with the parser removed, a raw string throws.

What we pick: Per locale and per namespace, precompiled. It meets the 15 KB first-page budget (8 to 13 KB in our model) and makes cache invalidation as small as a namespace. Precompiling trades a slightly larger catalog for no parser in the bundle and no parse time on a slow phone, and it moves syntax errors from users' screens to our publish job. The loading lab below shows the bytes and switch times for each option.

Strategy
RouteNetwork
Strategy per namespace on the hotel page over fast 4G. First page downloads 20 KB of translation data and parser code. Switching to another locale downloads 11 KB and takes about 70 ms. A German fix invalidates: 5 KB.hotel needs: common 4 KB, hotel 5 KB, reviews 2 KBall in bundle2889 KB72 msapp deployper locale99 KB212 ms90 KBper namespace20 KB70 ms5 KBprecompiled13 KB63 ms6 KBfirst pageswitch localea fix evicts
per namespace: 20 KB of strings plus parser on the first page (over the 15 KB budget), 70 ms to switch locale. Model numbers: 32 locales, a 90 KB catalog each, a 9 KB parser unless messages are precompiled.

Caching with content hashes

Each published file's name includes a hash of its content: /i18n/de/checkout.9c1e2a.json. A file with a given name never changes, so the CDN and the browser can keep it for a year with Cache-Control: public, max-age=31536000, immutable. What changes is a small manifest that maps each locale and namespace to its current file.

The server reads the manifest (cached in memory for 60 seconds) and writes the right URLs into the HTML it renders, so the browser never fetches the manifest on the first page. The cdn-caching lesson covers the general pattern; here it means a German fix costs a returning German user one small download of one namespace.

ts
type CatalogManifest = {
  version: string; // publish id, for rollback and logs
  files: Record<string, Record<string, string>>; // locale -> namespace -> URL
};

const routeNamespaces: Record<string, string[]> = {
  '/search': ['common', 'search'],
  '/hotel/[id]': ['common', 'hotel', 'reviews'],
  '/checkout': ['common', 'checkout', 'payments'],
};

async function loadMessages(locale: string, route: string, m: CatalogManifest) {
  const urls = routeNamespaces[route].map((ns) => m.files[locale][ns]);
  const parts = await Promise.all(urls.map((u) => fetch(u).then((r) => r.json())));
  return Object.assign({}, ...parts); // id -> precompiled AST
}
The route map lives next to the routes, so code-splitting and string-splitting follow the same boundaries.

On the first page, server rendering embeds the messages the page used into the HTML payload, so hydration needs no extra request. On client navigation, the router prefetches the next route's namespaces together with its code chunk when the link becomes visible or is hovered (the code-splitting lesson explains route prefetching). Formatting itself costs almost nothing in bytes: we use the browser's built-in Intl objects for plurals, numbers and dates instead of shipping locale data in a date library.

Check your understanding · Exercise 5

A Japanese user opens a hotel page, then goes to checkout. What do they download in our design? Select all that apply.

Runtime: picking the locale and switching it live

Two moments decide which language a user sees. The first request, when the server must choose a locale before it renders a single byte. And the switch, when a user picks another language from the menu. Getting either wrong produces the classic bugs: a French user bounced to English by a redirect, a page that flashes English before German arrives, or a switch that changes the text but leaves dates in the old format and the layout facing the wrong way.

Choosing the locale on the server

The server checks sources in a fixed order, and the first one that names a supported locale wins:

  1. The URL: /de/hotel/123. It's explicit, shareable, and what search engines crawl. If the URL has a locale, nothing else is consulted.
  2. The saved choice: a locale cookie set when the user picked a language, or the profile setting for a signed-in user.
  3. The browser's preference: the Accept-Language header, such as de-CH,de;q=0.9,en;q=0.8, matched against our 32 supported locales.
  4. The default: en.

Steps 2 to 4 only run on URLs without a locale, like the bare home page, and they end in a redirect to the localized URL. A localized URL never redirects based on headers. That rule protects search: crawlers usually don't send Accept-Language, so a header-based redirect on /fr/hotel/123 could send the crawler to English and the French page would never be indexed. The bare-root redirect response must not be cached publicly, or it must vary by cookie and header, or the CDN will send everyone to whichever language came first.

Matching isn't string equality. de-CH should land on de when we have no Swiss German. zh-TW should land on zh-Hant, which new Intl.Locale('zh-TW').maximize() reveals by adding the likely script: zh-Hant-TW. A small matcher walks the user's list in order of q weight and returns the first locale whose fallback chain hits a supported one.

Sequence of a first visit. The browser asks the server for the bare root with Accept-Language de-CH and no cookie. The server matches de and replies with a 307 redirect to /de/, marked private so the CDN won't cache it. The browser requests /de/. The server reads the manifest and the common and search namespaces for de from the catalog CDN, gets the hashed files, and replies with HTML that has lang de and the messages inlined. Later the browser prefetches the hotel namespace for de straight from the CDN.BrowserServerCatalog CDNGET /, prefers de-CH307 to /de/, privateGET /de/de: common + searchhashed JSON, cachedHTML lang=de + messagesprefetch de/hotel
Only the bare root is negotiated, and its redirect is Cache-Control: private. The localized page never looks at headers, so crawlers and shared links always get the language in the URL.

Switching without a reload

Decision What happens when the user picks a new language?

Navigate to the new locale's URL with a full reload

Set the cookie and assign location.href to /fr/... for the same page.

Pros

  • Simplest; the server renders everything fresh and correct.
  • No risk of stale formatters or half-switched state.

Cons

  • Loses client state: a half-filled booking form, scroll position, an open dialog.
  • Costs a full page load, often over a second on mobile.

Switch in place on the client

Fetch the new locale's namespaces for the current route, then swap locale and messages in the provider in one update.

Update the URL with the router, and the lang and dir attributes, then persist the choice.

Pros

  • Keeps form state and scroll; typically 100 to 300 ms.
  • The old language stays visible until the new one is fully ready, so nothing flashes.

Cons

  • Every locale-dependent value must re-render: formatters, cached strings in non-React code, the document title.
  • Server-only data that's locale-specific, like a localized hotel description, needs a refetch.

What we pick: Switch in place, with a full reload as the fallback. The booking flow has long forms where a reload would lose work, and the client already knows how to load namespaces. We make it safe by keeping all locale-dependent values derived from one provider value, so a single state update re-renders them all. If the catalogs fail to load, we keep the current language and show an error in it; if the app detects code that can't switch (an old embedded widget, for example), it falls back to the reload.

The order of operations matters. Load first, then commit: fetch every namespace the current route needs, and only when all of them arrive, update the provider. Mark the update as a React transition so the old screen stays interactive. In the same commit set document.documentElement.lang and dir, replace the URL so it reads /fr/... (no new history entry), and update the document title.

Then persist: set the cookie for a year and, for a signed-in user, save the choice to the profile so other devices follow. Data that came from the server in the old language, such as a localized hotel description, refetches under its new cache key, because the locale is part of every localized query key.

Sequence of a live language switch. The user picks French in the picker, which calls switch fr on the provider. The provider asks the loader for the French namespaces of the current route. The loader fetches them from the CDN and gets two files, 8 KB. The loader tells the provider the messages are ready, and the provider commits locale, messages, lang, dir and the URL in one update, which the picker shows. Failure path: the CDN times out after 5 seconds, and the loader tells the picker to keep the current language, English here, and show an error.PickerProviderLoaderCDNswitch('fr')load fr namespacesGET fr/*.json2 files, 8 KBmessages readyone commitfailure pathtimeout, 5 skeep en, show error
Load first, commit second. Until every namespace arrives, the old language stays on screen and usable, so a failed switch costs nothing but an error message. After the commit, the cookie and profile save the choice.

Check your understanding · Exercise 6

A user with Accept-Language: fr-FR,fr;q=0.9 and a locale=de cookie opens a shared link to /es/hotel/42. What should they see? Select all that apply.

Routing, search and right-to-left

Two parts of the system act on the whole page rather than on single strings: where the locale lives in the URL, and which direction the page flows. Both must be decided once, at the platform level, because retrofitting either across 14 teams is a quarter of work.

Decision Where does the locale live in the URL?

Path prefix: example.com/de/

Every route is nested under a [locale] segment; the root redirects to the negotiated locale.

Pros

  • One domain, one certificate, one CDN config; domain authority is shared across languages.
  • Easy to add a locale: no DNS work.

Cons

  • Every internal link must carry the prefix, so links go through a helper.
  • Can't host a market on separate infrastructure.

Subdomain: de.example.com

Each locale gets a subdomain; the app reads the host header.

Pros

  • Clean separation; a market can be routed to its own servers.

Cons

  • Cookies and auth must work across subdomains.
  • More DNS, certificates and CDN rules for 32 locales.

Country domains: example.de

One domain per country, usually for a market rather than a language.

Pros

  • Strongest local signal for users and search; fits companies with legal entities per country.

Cons

  • Expensive to buy and run 32 domains; logins span domains.
  • Mixes up language and country: Switzerland has four languages.

What we pick: A path prefix with the language, like /de/ and /pt-br/, and a region only when content truly differs. It's the cheapest to run and the easiest for 14 teams to get right through one link helper. Each page lists its alternates with hreflang links, including an x-default for the language picker page, as the i18n-performance-seo lesson explains. We keep currency and market separate from language: a German speaker in Zurich reads /de/ and pays in CHF, set by a market setting, not by the URL's language.

Right-to-left as a platform feature

For Arabic and Hebrew the server renders html lang="ar" dir="rtl", so the first paint is already mirrored. The browser then flips text flow, flex and grid order, and table columns by itself. What it can't flip are left and right written into CSS and icons. The rtl-layouts lesson covers the CSS; here's what the platform enforces:

  • Logical CSS only. A stylelint rule rejects margin-left, padding-right, left: and text-align: left in favor of margin-inline-start, padding-inline-end, inset-inline-start and text-align: start. The design system's components do this once; product teams inherit it.
  • Mirror icons that mean direction, not all icons. Back and forward arrows, a progress chevron and a "reply" arrow flip. A checkmark, a clock, a play button and brand logos don't. The icon set marks each icon as directional, and the Icon component flips those under :dir(rtl).
  • Isolate mixed-direction values. A hotel name in Latin letters inside an Arabic sentence can scramble nearby punctuation. Wrap user-supplied values in bdi elements, or give inputs dir="auto", so each value keeps its own direction.
  • Motion and gestures follow direction. A drawer opens from the inline-start side, a carousel's next is on the left in Arabic, and a swipe to go back goes the other way.
The checkout step in English and in Arabic. In English, the back arrow is on the left pointing left, the steps run 1, 2, 3 from left to right, the card field has a lock icon, and the Pay button sits at the right. In Arabic, the back arrow is on the right pointing right, the steps run from right to left with step 1 on the right, the Pay button sits at the left, and the lock icon and the card digits are not mirrored.en, dir=ltr←Checkout1234242 4242 4242lockPayar, dir=rtl→الدفع1234242 4242 4242lockادفعflips: arrow, steps, button sidestays: lock, card digits
Direction-carrying parts flip: the back arrow, the step order, the button side. Meaningful shapes and numbers don't. With dir="rtl" and logical properties, only the icon needs code.

Switching direction at runtime is the same commit as switching language: set dir on the html element and every logical property recomputes. That works only because nothing hard-coded left or right. Components that measure positions in JavaScript, like a tooltip or a virtualized carousel, must read the direction from the provider instead of assuming left.

Check your understanding · Exercise 7

Which of these should flip in the Arabic version of the checkout page? Select all that apply.

Formatting: dates, numbers and time zones

Translated strings are half of what users read. The other half is values: prices, dates, counts, durations, lists of amenities. The browser's Intl APIs format all of them for any locale with no data to download, and the locale-formatting lesson covers each API. The system design questions are different: which locale and time zone get passed in, where formatters are created, and how server and client agree.

The naive code calls price.toLocaleString() and date.toLocaleDateString() with no arguments. On the client that uses the browser's language, not the app's, so a German page shows US dates for a user whose browser is set to English. On the server it uses the server's settings, usually en-US and UTC. The two disagree, and React reports a hydration mismatch on every date.

The rule: every format call gets the app's locale and an explicit time zone, and both come from the same provider on server and client.

Same values, two locales. Separators, symbol position, word order and the serial comma all come from CLDR data built into the browser.
ValueAPIen-USde-DE
Price 1234.5 EURIntl.NumberFormat with style: 'currency'€1,234.501.234,50 €
Check-in dateIntl.DateTimeFormat with dateStyle: 'medium'Mar 5, 202605.03.2026
Booked a day agoIntl.RelativeTimeFormat with numeric: 'auto'yesterdaygestern
AmenitiesIntl.ListFormat with type: 'conjunction'Wi-Fi, Pool, and ParkingWi-Fi, Pool und Parking
Which plural formIntl.PluralRulesselect(21) is otherselect(21) is other (Russian: one)

Create formatters once

Creating an Intl formatter means resolving locale data and options, which is far slower than calling format() on an existing one. A search page with 50 hotel cards, each with a price, a rating and a date range, would create hundreds of formatters per render. The platform exposes formatting through hooks backed by a cache keyed by locale and options, so each combination is built once per session. FormatJS already memoizes its formatters this way; our own helpers for non-React code, such as chart labels, use the same cache.

ts
const cache = new Map<string, Intl.NumberFormat>();

export function numberFormat(locale: string, options: Intl.NumberFormatOptions = {}) {
  const key = locale + JSON.stringify(options);
  let f = cache.get(key);
  if (!f) {
    f = new Intl.NumberFormat(locale, options);
    cache.set(key, f);
  }
  return f;
}

// numberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5)
// -> '1.234,50 €'
One cache per formatter type. Options objects must be serialized in a stable order for the key to match, so callers pass options from shared constants.

Whose time zone?

Time zone is a separate setting from language, and our product has three kinds of time. Store every instant in UTC, then pick the zone by meaning:

  • Place times use the place's zone. "Check-in from 15:00" is 15:00 at the hotel in Lisbon, whatever zone the user is in. The hotel record carries timeZone: 'Europe/Lisbon'.
  • Personal times use the user's zone: "Booked on 3 March at 21:40" in their profile zone, or the browser's Intl.DateTimeFormat().resolvedOptions().timeZone for guests.
  • Relative times like "2 hours ago" don't need a zone, but they change as time passes, so the server renders an absolute date and the client upgrades it after hydration.

Region preferences go beyond time zones: the first day of the week (Monday in Germany, Sunday in the US, Saturday in some Middle Eastern countries) matters for the calendar, which the date-picker guide handles. And currency follows the market setting, never the language: de with CHF is a valid pair.

Check your understanding · Exercise 8

German hotel pages show dates correctly after load, but React logs hydration mismatches on every date. What's the likely cause? Select all that apply.

Architecture, data model and the developer API

With the decisions made, the system has three halves that meet at the catalog files. Build time turns code into source strings, and the localization pipeline turns source strings into validated, compiled catalogs on the CDN. Runtime picks a locale and renders with those catalogs. Each half can fail or change without breaking the others, which is why the boundaries sit where they do.

The i18n system's architecture in three columns. Build, in CI: components declare messages with defineMessages, pull requests pass lint rules, and on merge the extractor writes English source with context. Pipeline: the extractor sends source to the TMS, approved translations go to the publish job, which validates and compiles them and uploads hashed files to the catalog CDN. Runtime: the middleware picks the locale for the I18n provider, the loader fetches namespaces from the CDN and gives the catalog to the provider, and the provider feeds the UI components, which report missing ids to a log.Build (CI)PipelineRuntimeExtractoren + contextLint rulesComponentsdefineMessageson PRon mergeCDN is thecontractTMStranslate, reviewPublish jobvalidate, compileCatalog CDNhashed filesMissing-id logapproveduploadMiddlewarepicks localeI18n providerlocale, dir, msgsLoadernamespacesUI componentslocalecatalog
Each column can change or fail without breaking the others. The CDN is the contract between pipeline and runtime: a TMS outage stops new translations, never the site.
PartWhere it runsJob
Message declarationsIn componentsid, defaultMessage, description next to the UI that uses them.
Lint rulesEditor and CINo literal text in JSX, descriptions required, ids follow the naming convention.
ExtractorCI on mergeReads declarations, writes en source with fingerprints, fails on duplicate ids.
TMS connectorCI and webhookPushes source and context to the TMS; receives approval events.
Publish jobWorkerValidates, merges fallback chains, precompiles, uploads hashed files, writes the manifest.
Catalog CDNEdgeServes immutable catalog files and the short-lived manifest.
Locale middlewareServer, per requestNegotiates the locale, redirects the bare root, sets lang and dir.
I18n providerServer and clientHolds locale, direction, time zone, market and messages; one value drives every re-render.
LoaderClientFetches namespaces for a route, dedupes requests, prefetches on link hover.
ReporterClientBatches missing-id and format-error events to our logging endpoint.

Why these cuts? The extractor and the publish job are separate so developers never wait for translators, and translators never wait for a deploy. The CDN sits between the publish job and the app so a TMS outage can't take the site down: the last published catalogs keep serving. And everything locale-dependent in the app reads from one provider value, which is what makes a live switch a single re-render instead of a hunt for stale strings.

Data model

ts
type MessageSource = {
  id: string;              // 'checkout.summary.payButton'
  defaultMessage: string;  // ICU, English
  description: string;     // for translators
  fingerprint: string;     // hash of defaultMessage
  maxLength?: number;
  placeholders: string[];  // ['host', 'guests'], from the parsed message
};

type TranslationState = 'new' | 'translating' | 'review' | 'approved' | 'stale' | 'obsolete';

type PublishedNamespace = {
  locale: string;          // 'pt-PT'
  namespace: string;       // 'checkout'
  messages: Record<string, unknown>; // id -> precompiled AST
  fallbackIds: string[];   // served from 'pt' or 'en'
};

type LocaleContext = {
  locale: string; dir: 'ltr' | 'rtl'; timeZone: string;
  market: { country: string; currency: string };
};
Sources and states live in CI and the TMS; published namespaces live on the CDN; the locale context lives in the provider, the URL and the cookie.

What app developers write

The platform's success is measured by how little developers think about it. They get two hooks and one component, and never touch a locale file:

tsx
const m = defineMessages({
  cancelNote: {
    id: 'checkout.summary.cancelNote',
    description: 'Below the price. date = last free cancellation day.',
    defaultMessage: 'Free cancellation until {date}. <terms>Read the policy</terms>',
  },
});

function CancelNote({ until }: { until: Date }) {
  const t = useT();
  const { formatDate } = useFormat(); // locale and time zone from the provider
  return (
    <p>
      {t(m.cancelNote, {
        date: formatDate(until, { dateStyle: 'long' }),
        terms: (chunks) => <Link href="/policy">{chunks}</Link>,
      })}
    </p>
  );
}
The terms tag in the message maps to a React component. The translator controls the words inside it, never the markup.

The language picker is a platform component too, so every team gets the same behavior. It lists each language in its own name and script (Deutsch, العربية, 日本語), because a user who can't read the current language must still find theirs. It separates language from region formats and time zone, and shows a live preview of a date and a price before the user commits.

Wireframe of the language and region settings. On the left, a list of languages each in its own name: English (US), Deutsch, which is selected, العربية, 日本語 and Português (Brasil). On the right, a Region formats select set to Switzerland, a Time zone select set to Europe/Zurich, and a currency select set to CHF. Below them a preview shows the date 5. März 2026 and the price CHF 1'234.50. An Apply button is at the bottom right.Language and regionLanguage, in its own nameEnglish (US)Deutschالعربية日本語Português (Brasil)Region formatsSwitzerland ▾Time zoneEurope/Zurich ▾CurrencyCHF ▾Preview5. März 2026CHF 1'234.50Apply
Language, region formats, time zone and currency are four separate settings: a German speaker in Zurich reads German and sees Swiss prices. The preview shows the result before anything is committed.

Check your understanding · Exercise 9

The TMS vendor has a four-hour outage. What breaks in our design? Select all that apply.

Quality and safety: catching problems before users do

With 6,000 strings, 32 locales and many deploys a day, mistakes are a certainty: a hard-coded English label, a button that truncates in German, a translation that drops {count}, a link tag typed as raw HTML. The platform's job is to catch each kind as early and as cheaply as possible: in the editor, in CI, at publish time, and finally in production monitoring.

Pseudo-localization: test every language before it exists

A pseudo-locale is a fake language generated by a script from English, built to expose bugs. Android ships two that make good models: an accented one (en-XA) and a right-to-left one (ar-XB). Ours are generated by the publish job for every build:

  • Accented and expanded: Pay now becomes [Ƥåý ñöŵ one two]. Accents prove the text went through the catalog; any plain English on screen is a hard-coded string. Padding of about 40 percent shows where longer languages will overflow. Brackets show truncation: a missing ] means the end was cut off, and brackets in the middle of a sentence reveal concatenation.
  • Bidi: English words reversed inside right-to-left marks, with dir="rtl". Engineers who can't read Arabic can still see a broken mirror: a left-pointing back arrow or a margin on the wrong side.
  • Ids: each string wrapped with its id, used by the screenshot job for translator context.
A hotel card in English and in the pseudo-locale. In English: the title Sea view room, a Reserve button, a Book now button, and a line 2 nights, 1 guest. In the pseudo-locale: the title becomes accented and bracketed, the Reserve button text is cut off with no closing bracket, which shows truncation, the Book now button is still plain English, which shows a hard-coded string, and the nights line shows two bracketed pieces, which shows concatenation.enSea view roomReserveBook now2 nights, 1 guesten-XA pseudo[Šéå ṽîéŵ ŕööm one two][Ŕéšéŕṽé oneBook now[2 ñîĝĥţš] [1 ĝûéšţ]cut offhard-codedtwo messages glued
Three bugs, found before any translator saw the screen: plain English means hard-coded, a missing closing bracket means truncation, and brackets mid-sentence mean concatenation.

Pseudo-locales run in CI visual tests on the 30 most important screens and are selectable on staging. Combined with an ESLint rule that flags literal text in JSX (such as formatjs/no-literal-string-in-jsx or i18next/no-literal-string), they catch nearly every hard-coded string before a translator ever sees the feature.

Validating translations at publish time

The publish job parses every translation and compares it with its source. It rejects a string, keeps the last good version live, and reports the error back to the TMS when:

  • A placeholder is missing or renamed: the source has {count} and the translation has {cnt} or none. The UI would show a wrong or empty value.
  • A plural or select has no other branch, or the message doesn't parse.
  • Tags don't match: the source has terms and the translation has term, a missing closing tag, or a tag the source doesn't have.
  • Raw HTML appears, such as an a or script tag. Translations never carry markup.

It warns, without rejecting, on plural categories the language doesn't use (a few branch in German is dead text), on length above maxLength, and on glossary violations. Most TMS tools run similar checks in the translator's editor; we repeat them because the publish job is the last gate we control.

Decision How should a translated sentence contain a link or bold text?

HTML in the translation, rendered with dangerouslySetInnerHTML

The message is Read our <a href="/terms">terms</a>; the app injects it as HTML, maybe after a sanitizer.

Pros

  • Translators can place the link anywhere; no code per tag.

Cons

  • Any translator, vendor account or TMS breach can inject script into every page: stored XSS at scale.
  • Translators can change URLs and attributes.

Split the sentence around the link

Three messages: before, link text, after; code puts a link around the middle one.

Pros

  • No HTML anywhere.

Cons

  • It's concatenation again: word order is fixed by code and translators see fragments.

Named tags mapped to components

The message is Read our <terms>policy</terms>; code maps terms to a Link component; the formatter treats the tag as structure, never as HTML.

Pros

  • Whole sentence for the translator; markup, URLs and attributes stay in code.
  • The publish job can verify the tag set matches the source.

Cons

  • Every tag needs a mapping in code; in FormatJS an unmapped tag is a formatting error, so the publish check must compare tag sets.

What we pick: Named tags mapped to components. It gives translators full control of word order and none over markup, which is the right split of power: the xss lesson's rule that untrusted text is never parsed as HTML applies to translations too, because a vendor's translator is outside our trust boundary. The lint rule forbids dangerouslySetInnerHTML near any message call.

Watching production

Some problems only show up live. The provider's error handler reports two event types to our logging endpoint, batched and deduplicated per session: i18n.missing (an id with no entry, which should only happen when code and catalog are out of step) and i18n.format_error (a message that threw while formatting, for example a select value nobody planned for). Each event carries the id, locale, route and catalog version.

A dashboard ranks ids by affected users, and the fallback lists from the publish job give a second view: what share of each locale's strings is still English, per namespace. A release that pushes de checkout below 98 percent translated gets flagged before launch.

User-generated content is not UI

Reviews and host messages are written by users in their own language, and they share the page with our translated UI. They never go through the TMS. Instead, the server detects each review's language when it's submitted and stores it, the page renders each review inside an element with that lang (so screen readers pronounce it correctly and the browser picks the right fonts and hyphenation), and inputs use dir="auto" so an Arabic review typed on an English page flows correctly.

A "Show translation" button calls our backend, which machine-translates on demand and caches the result per review and target language. The translated text is labelled as machine translated, carries the target lang, and offers the original one click away.

Check your understanding · Exercise 10

Which problems would the accented, expanded pseudo-locale reveal in a CI screenshot? Select all that apply.

Running it in the interview

This question is wide, and the trap is spending 30 minutes on ICU syntax. Interviewers want to see the system: how strings move, how the runtime stays small and correct, and how mistakes get caught. Here's a plan for 45 minutes.

  1. Minutes 0 to 5, scope. Ask about languages (any RTL?), who translates, whether releases can wait, rendering model, and SEO. Write the numbers on the board: 32 locales, 6,000 strings, deploys many times a day.
  2. Minutes 5 to 8, what's hard. Name the four properties: grammar, two clocks, payload, untrusted translators. Say which chapter of your answer handles each.
  3. Minutes 8 to 15, messages and ids. Show the concatenation bug, pick ICU, write one plural message. Pick semantic ids colocated with English and explain what happens when English changes.
  4. Minutes 15 to 25, the pipeline. Draw extractor, TMS, publish job, CDN, manifest. Explain context for translators, review states, fallback chains merged at publish time.
  5. Minutes 25 to 35, runtime. Locale negotiation order, the no-redirect rule, per-namespace precompiled loading with content hashes, live switching with load-then-commit.
  6. Minutes 35 to 45, depth. RTL, formatting with time zones, pseudo-locales, publish-time validation, rich text tags, and user content. Let the interviewer steer which one.

Draw the architecture diagram first, as three columns: build, pipeline, runtime, meeting at the CDN. Every later point attaches to a box on it. Say trade-offs out loud as numbers: "one catalog is 90 KB, per-namespace is 8 KB for the search page" lands better than "it's more efficient".

Think first

The interviewer asks why you don't adopt MessageFormat 2 now, since it's the new standard. What do you answer?

Think first

The interviewer adds: our iOS and Android apps need the same strings. What changes?

Think first

Strings grow to 50,000 and teams complain the extractor slows CI. What do you do?

Summary

  • A message is a whole sentence with named placeholders and all its plural and select variants inside it. Code passes values; the translator owns words and order. ICU MessageFormat is the format every major TMS validates.
  • Use semantic ids with English, a description and a fingerprint colocated in code. Typos keep the id and send translations to review; new meanings get new ids.
  • Continuous localization: extract on merge, give translators context (descriptions, screenshots, max length, glossary), review, then publish through our own validating job to our CDN, without waiting for an app deploy.
  • Merge fallback chains such as pt-PT to pt to en at publish time from CLDR parent data, so the client loads one file and does one lookup.
  • Load only the active locale and the current route's namespaces, precompiled and content-hashed; embed first-page messages in the server HTML and prefetch the next route's.
  • The URL decides the locale, then the saved choice, then Accept-Language; only the bare root redirects. Switch in place by loading first, then committing locale, messages, lang and dir in one update.
  • RTL, formatting and time zones are platform features: logical CSS enforced by lint, directional icons flagged, every format call given the provider's locale and an explicit zone.
  • Guard the path from translator to user: pseudo-locales in CI, placeholder and tag validation at publish, rich text as tags mapped to components, and production reports of missing ids.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next