cf.completefrontend
My progress
FUNDAMENTALS · MODULE 13

Translating an app

How an app speaks many languages: text in per-locale message catalogs, ICU messages so plurals and gender work everywhere, catalogs loaded per locale and route, clear fallbacks, and a safe workflow with translators.

The problem: text that is welded to the code

Picture a team building a shopping app in English. The cart page has a line that says how many items you have. Someone writes it the obvious way, in JSX: the count, then the word items, then in your cart. It ships, it works, nobody thinks about it again.

A year later the company launches in Germany, France, Poland, and Saudi Arabia. Product asks for a translated app in six weeks. The team opens the code and finds about 4,000 English strings spread across 900 components: in JSX, in error messages, in aria-labels, in toast calls, in email templates, and glued together with + and template literals.

Every string now has to be found, named, sent to translators, and put back. Worse, many cannot be translated as they are. You have + count + items assumes the number comes first, that there is one plural form, and that adding s makes it. None of that holds in Polish or Arabic.

What actually breaks

Translating an app that was not built for it produces the same bugs every time. Each one maps to a part of this lesson.

  • Wrong plurals. English has two forms (1 item, 2 items). Polish has different endings for 2 to 4 and for 5 to 21, and Arabic has six forms. Code that checks count === 1 is wrong in most of the world.
  • Broken word order. Concatenating "Hello, " + name + "!" fixes the order of the parts. In Japanese the name comes before the greeting word, and the translator cannot move it.
  • Lost context. A translator gets the single word Open with no screenshot. Is it a verb (open the file) or an adjective (the store is open)? Many languages use different words.
  • Text that does not fit. German is often 30 percent longer than English, and short labels can double. Buttons overflow and labels get cut off.
  • Slow pages. The team puts every string for every language in one JSON file and ships it in the main bundle. Users download 40 languages to read one.
  • Missing strings. A new button ships on Friday; its translations arrive two weeks later. Until then, does the German user see an empty button, a raw key like cart.checkout.cta, or English?
Two panels side by side. Before: English text lives inside the code, in CartFooter.tsx as You have plus n plus items, in Toast.ts as Saved!, and in an API error as Card declined, about 4,000 strings spread across 900 files. After: CartFooter.tsx calls t with the key cart.summary and the count, and the words live in one catalog per locale: en.json, de.json, pl.json, and ar.json.Before: words in codeCartFooter.tsx"You have " + n + " items"Toast.ts"Saved!"API error"Card declined"4,000 strings in 900 filesAfter: keys and catalogsCartFooter.tsxt("cart.summary", { count })en.jsonde.jsonpl.jsonar.jsonone file per locale
Before, every language change means editing components. After, components only name messages like cart.summary, and adding a language means adding a file.

What translation architecture means

Internationalization, shortened to i18n (an i, 18 letters, an n), is the engineering work that makes an app ready for many languages and regions. Localization, or l10n, is the work of actually adapting it to one of them: translating text, and checking dates, numbers, images, and tone. Engineers build i18n once; translators and local teams do l10n for each market.

Translation architecture is the text part of i18n. It answers five questions: where the strings live, how the code refers to them, how a string handles numbers and genders, how the right strings reach the browser, and what happens when one is missing. A sixth question sits outside the code: how strings flow to translators and back.

Formatting dates, numbers, and currencies is covered in locale-formatting, mirroring layouts for Arabic and Hebrew in rtl-layouts, and fast, findable localized pages in i18n-performance-seo. This lesson stays with the strings.

Check your understanding · Exercise 1

A component renders {count} item{count === 1 ? "" : "s"} in your cart. Which problems will it have once the app is translated? Select all that apply.

The mental model: keys in the code, words in catalogs

Think of a restaurant chain that serves the same dishes in ten countries. The kitchen never reads the menu. It works from dish numbers, so order 14 is the same soup in Paris and in Tokyo. Each country prints its own menu, written by a local, that says what dish 14 is called there.

Your components are the kitchen. Keys are the dish numbers. Each message catalog is one country's menu. When a new dish has no local name yet, the menu prints the head office's name rather than a blank line: that is the fallback.

PayButton asks the i18n runtime for the key checkout.pay with an amount. The runtime knows the locale is de, so it reads de.json, which says {amount} bezahlen, rather than en.json, Pay {amount}, or fr.json, Payer {amount}. It fills in the amount, and the button says 42,00 € bezahlen.PayButtonasks for a keyi18n runtimelocale: deen.jsonPay {amount}de.json{amount} bezahlenfr.jsonPayer {amount}42,00 € bezahlencheckout.payfills in the amount
The component only knows the key checkout.pay and the value. The runtime picks the catalog for the locale, formats the amount the German way, and puts it where the German sentence wants it: first.

The four parts

  1. A locale. The language and optional region or script the user reads, written as a BCP 47 tag (the IETF standard for language tags): en, en-GB, pt-BR for Brazilian Portuguese, zh-Hant for Chinese in traditional characters. The app picks it from the URL, a saved setting, or the Accept-Language header.
  2. A catalog per locale. A file that maps each key to the text in that language. Most web apps use JSON. Others use gettext PO files, XLIFF (an XML format many translation tools read), or Android-style XML. Large apps split each locale into namespaces: smaller files per feature, like common, cart, and settings.
  3. A translate function. Usually called t or formatMessage. Components call it with a key and values, like t("cart.summary", { count: 3 }).
  4. A runtime. A small library that holds the current locale's catalogs, finds and formats the message for a key, and handles missing keys. FormatJS, i18next, Lingui, and next-intl are all runtimes of this kind.
tsx
// locales/en/cart.json
{ "cart": { "checkout": "Go to checkout", "summary": "{count, plural, one {# item} other {# items}}" } }

// locales/de/cart.json
{ "cart": { "checkout": "Zur Kasse", "summary": "{count, plural, one {# Artikel} other {# Artikel}}" } }

function CartFooter({ count }: { count: number }) {
  const t = useTranslations("cart");
  return (
    <footer>
      <p>{t("summary", { count })}</p>
      <a href="/checkout">{t("checkout")}</a>
    </footer>
  );
}
The component knows keys and values, never words. German happens to use the same noun for one and many here; the translator decides that, not the code.

Text hides in more places than JSX

Visible paragraphs are the easy part. Text also hides in alt attributes, aria-labels, title and placeholder attributes, the page title, toast and dialog calls, form validation messages, emails and push notifications, and the manifest.json name.

Errors from your own API are the classic leak. If the server returns "Card declined", a German user reads English. Return a stable code like card_declined with values as data, and let the client translate it. Keep log messages and analytics event names in English: they are for engineers, and they must stay searchable.

Naming keys: semantic or source text

There are two ways to name a message, both in wide use, and the choice shapes your tooling for years.

Decision How should code refer to a message?

Semantic keys

Each message gets a hand-written name that says what it is for, like checkout.payButton.

The English text lives only in the English catalog, like every other language.

Pros

  • Stable: fixing a typo in the English does not change the key, so translations stay attached.
  • Keys group by feature, which makes namespaces and per-route loading natural.
  • Writers can edit English in the translation tool without a code change.

Cons

  • You open the catalog to see what a component says.
  • Naming thousands of keys takes discipline; teams end up with button1 and newTitle2.

Source-text keys

The English is written in the code and used as the key, as gettext does: t("Go to checkout").

Modern tools like Lingui hash the source text (plus an optional context) into a short id at build time.

Pros

  • Code reads naturally and nobody has to invent names.
  • Extraction is simple: every string in a t call is a message.

Cons

  • Changing the English wording makes a new message; existing translations are orphaned unless the tool carries them over.
  • The same English in two places becomes one message unless you add a context, even when the meanings differ.

What we pick: Product apps with a translation team usually pick semantic keys plus a defaultMessage or description next to the call. Source-text keys suit smaller teams that want readable code, if the tool supports a context for ambiguous words. Either way, pick one style and enforce it with lint.

Rules that keep a catalog healthy

  • One key per meaning, not per word. English Open is a verb on a button and an adjective on a status badge. German needs Öffnen for one and Geöffnet for the other, so they need two keys.
  • Don't share keys across screens to save effort. The day one screen's wording changes, the other changes too. Share only true constants like common.cancel.
  • Never build keys at runtime. t("status." + status) cannot be found by extraction tools, so those keys are never sent to translators and go missing in silence. Write each key out in a switch or a map.
  • Describe every key. Tell the translator where the text appears, how much room it has, and what each placeholder holds.

Check your understanding · Exercise 2

Which of these are good practice for message keys? Select all that apply.

ICU MessageFormat: one string that holds the whole sentence

Once text lives in catalogs, the next question is how a message handles values that change the grammar: a count, a person's gender, a position like 1st or 2nd. The answer most of the industry settled on is the ICU MessageFormat syntax, named after ICU (International Components for Unicode), the library that browsers, Java, and Android use for this kind of work.

The whole sentence, with every variant, is one string in the catalog. The translator owns all of it; the code passes raw values and never decides which words to show.

The ICU message for cart.summary split into five lines with notes. {count, names the value passed from code. plural, is the type, which could also be select or selectordinal. =0 {Your cart is empty} is an exact match, checked first. one {# item in your cart} is a CLDR plural category, where # prints the count. other {# items in your cart}} is the required branch, the safety net.{count,the value's name, from codeplural,or select, selectordinal=0 {Your cart is empty}exact match, checked firstone {# item in your cart}a CLDR category; # = countother {# items in your cart}}required: the safety net
One string holds every variant. The translator rewrites the branches for their language, and the code only passes count.

The syntax in five pieces

  • Simple argument: {name} inserts a value as text. Hello, {name}! can become {name}さん、こんにちは! in Japanese, with the name first.
  • Plural: {count, plural, one {...} other {...}} picks a branch by the number's plural category. Inside a branch, # prints the number, formatted for the locale (1,234 in English, 1.234 in German).
  • Exact match: =0 {...} or =1 {...} inside a plural matches that exact number and wins over the category. Teams use =0 for empty states.
  • Select: {role, select, admin {...} member {...} other {...}} picks a branch by a string value. It is how messages handle grammatical gender and any other word that changes the sentence. other is required in both plural and select.
  • Ordinal: {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} picks by ordinal category, for 1st, 2nd, 3rd, 4th. English has four ordinal categories even though it has only two cardinal ones.
json
{
  "cart.summary": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}",
  "invite.sent": "{gender, select, female {She invited you} male {He invited you} other {They invited you}}",
  "race.result": "You finished {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
  "share.title": "{gender, select,
    female {{count, plural, one {She shared # photo} other {She shared # photos}}}
    other {{count, plural, one {They shared # photo} other {They shared # photos}}}
  }"
}
Plural, select, and ordinal, plus one nested message. Nesting puts full sentences inside each branch, so translators never stitch fragments.

Plural categories come from CLDR, not from English

The branch names are not numbers. They are plural categories defined by CLDR, the Unicode Common Locale Data Repository: a shared database of language rules that ICU and every browser's Intl APIs read. There are six possible categories, zero, one, two, few, many, and other, and each language uses only the ones its grammar needs.

LanguageCardinal categoriesWhich numbers go where
Japanese, Chinese, KoreanotherEvery number: nouns do not change with count.
English, Germanone, otherone is exactly 1; everything else, including 0 and 1.5, is other.
Frenchone, many, otherone is 0, 1, and fractions below 2 like 1.5. many is for exact millions like 1,000,000 (1 000 000 de fichiers).
Polishone, few, many, otherone is 1; few is 2 to 4, 22 to 24, 32 to 34 (but not 12 to 14); many is 0, 5 to 21, 25 to 31; other is for fractions.
Russianone, few, many, otherone is 1, 21, 31, 101 (not 11); few is 2 to 4, 22 to 24; many is 0, 5 to 20, 25 to 30.
Arabiczero, one, two, few, many, otherzero is 0; one is 1; two is 2; few has last two digits 3 to 10 (3, 103); many has last two digits 11 to 99 (11, 111); other is 100 to 102, 200 to 202, and fractions.

Two traps hide in that table. First, one does not mean the number 1: in Russian, 21 is one (21 файл, like 1 файл), and in French, 0 is one. Code that writes count === 1 ? one : other is wrong for both. Second, Arabic has a separate dual form for exactly two, so a natural Arabic message for 2 often drops the number and uses the dual noun alone.

You never write these rules yourself. The runtime asks Intl.PluralRules, which every modern browser ships, for the category of a number in a locale. new Intl.PluralRules("ar").select(11) returns "many", and new Intl.PluralRules("en", { type: "ordinal" }).select(22) returns "two". The locale-formatting lesson covers Intl in depth.

Locale
Count
The English message for a file count, with 3 branches: =0: No files; one: # file; other: # files. For the count 1, Intl.PluralRules returns the category one, and branch "one" is used. The rendered text is 1 file.Intl.PluralRules("en").select(1) = "one"branch "one" is used=0No filesone# fileother# files1 file
The category comes from your browser's Intl.PluralRules, which reads CLDR. Each language's message lists only the branches its grammar needs; other is always there as the safety net. Pseudo-localization keeps # intact.

Try it: pick Polish and step the count from 1 to 25. The branch jumps between one, few, and many in a pattern no if statement in English code would guess. Then pick Arabic and watch all six branches fire between 0 and 102.

What each locale's message must contain

The English source has two branches, but the Polish translation of the same key needs four, and the Arabic needs six. That is normal: each locale's catalog lists the categories its own language uses, plus other. A good TMS (translation management system, covered later) shows the translator exactly which categories their language needs, with example numbers for each.

If a translation is missing a category, the runtime falls back to the other branch. That is why other is required: it is the safety net. A CI check that every plural in every locale has its language's categories catches most plural bugs before users do.

A note on MessageFormat 2

Unicode MessageFormat 2 (MF2) is a redesigned syntax that fixes some pain points of the classic one: clearer escaping, matching on several values at once, and custom functions. It became a stable part of CLDR in 2025. A matching Intl.MessageFormat API is a Stage 1 proposal for JavaScript, and most web libraries still use the classic ICU syntax, so learn that first.

Check your understanding · Exercise 3

Which statements about ICU plurals are true? Select all that apply.

Writing messages that survive translation

ICU gives you the tools, but developers still write the English source, and most translation bugs start there. This chapter is a set of habits. Each one exists because a specific language breaks the shortcut it replaces.

Never build a sentence from pieces

Joining translated fragments, like t("youHave") + count + t("items"), looks harmless because each piece is translated. It still fails three ways: the code fixes the word order, the noun cannot agree with the number, and the translator sees You have and items as two unrelated strings.

The fix is always one message per sentence, with placeholders for the values; a sentence with two counts gets one message with two plural arguments. Translators can then reorder whole clauses, as Japanese, Turkish, and German often need.

tsx
// Bad: three fragments, English order, English plural
<p>{t("youHave")} {count} {count === 1 ? t("item") : t("items")}</p>

// Good: one message, translator owns order and plural
<p>{t("cart.summary", { count })}</p>

// Bad: the date is formatted in code, then glued in
<p>{t("shipsOn")} {date.toLocaleDateString()}</p>

// Good: the message formats the date for the locale
// "shipsOn": "Ships on {date, date, long}"
<p>{t("shipsOn", { date })}</p>
Pass raw values (numbers, Date objects) and let the message format them. Pre-formatted strings lose the locale's rules.

Interpolation: pass raw values, name them well

A placeholder is a named hole in a message, filled at runtime. Name placeholders for what they hold, {customerName} or {orderTotal}, not {0} or {value}: the name is the only hint the translator has. ICU lets a placeholder carry a format, so {total, number, ::currency/EUR} prints €1,234.50 in English and 1.234,50 € in German without any formatting code.

FormatJS, Lingui, and next-intl use ICU single braces: {name}. i18next uses double braces by default, {{name}}, and its own plural keys like item_one and item_other, with ICU available as a plugin.

Rich text: tags inside the message

Designs often want part of a sentence styled or linked: By continuing you accept our followed by a link to Terms. The tempting answer splits the sentence around the link. That brings back every concatenation bug, and in German part of the verb often comes after the link.

Instead, put lightweight tags in the message and map them to components in code. The catalog holds By continuing you accept our <terms>Terms</terms>. and the code says what terms means: a link to /terms. The translator moves the tag wherever their grammar needs it.

A tree for the message signup.accept. It has three children: the text By continuing you accept our, a tag named terms, and the text full stop. The terms tag has one child, the text Terms. An arrow shows the tag rendered as a link to /terms, which is decided by your code, not the catalog.signup.acceptone messagetextBy continuing you accept ourtag: termstexta full stoptextTermslink to /termsfrom your codethe translator owns the tree; the code owns the tag
The runtime parses the message into a tree. Text nodes render as plain text; each tag calls the function your code mapped to it, here a link to /terms. Unmapped tags never become HTML.
tsx
// en: "accept": "By continuing you accept our <terms>Terms</terms>."
// de: "accept": "Wenn du fortfährst, stimmst du unseren <terms>AGB</terms> zu."

// FormatJS (react-intl)
<FormattedMessage
  id="signup.accept"
  values={{ terms: (chunks) => <a href="/terms">{chunks}</a> }}
/>

// next-intl
t.rich("accept", { terms: (chunks) => <a href="/terms">{chunks}</a> })

// react-i18next
<Trans i18nKey="accept" components={{ terms: <a href="/terms" /> }} />
Three libraries, one idea: the message owns where the tag goes; the code owns what the tag becomes.

This is also a security rule. Translations come from outside your team (agencies, vendors, sometimes the community), so never render them through dangerouslySetInnerHTML or innerHTML to get bold or links. A tag in a message maps only to the components you listed, and everything else renders as plain text, so a bad translation cannot inject a script. The xss lesson covers why this matters.

Gender, lists, and other grammar

  • Gender. Many languages change verbs and adjectives by the gender of a person, not just pronouns. Pass the value you have (female, male, or other when unknown) and use select. Never infer gender from a name.
  • Lists. Alice, Bob, and Carol uses an Oxford comma in American English, Alice, Bob und Carol in German, and different separators in Japanese. Use Intl.ListFormat (see locale-formatting), not join(", ").
  • Words inside other words. Don't insert a translated noun into another message, like Delete {itemType}? with itemType as a separate translated word. In many languages the article and verb change with that noun. Write one message per item type.
  • Literal braces. In ICU, { and } are syntax. To show one, quote it: '{'. Prefer a typographic apostrophe in prose so quoting rules never surprise a translator.

Leave room for longer text

Translations are usually longer than English. The W3C's guidance is that short strings grow the most: a 10-character English label can double or triple, while long paragraphs grow by about 30 percent. German compound nouns can be very long and have no natural break points.

Design for it: let buttons grow instead of fixing their width, allow labels to wrap to two lines, and avoid text baked into images. For tight places like tab bars, put a maximum length in the key's description so the translator can choose a shorter word.

Check your understanding · Exercise 4

Design wants Read our Privacy Policy before you continue, with Privacy Policy as a link. Which approaches translate correctly? Pick one.

Loading translations per locale and per route

Catalogs are data, and data has weight. A large app easily has 6,000 messages: at about 70 bytes each with its key, that is 420 KB of JSON per locale, roughly 100 KB after gzip. Across 25 locales the full set is over 10 MB, yet each user reads one locale and each page uses a small slice of it.

So the loading rule is: one locale per user, and within it, only the namespaces the current route needs. It is the same idea as route-based code splitting (see code-splitting), applied to text.

A bar chart of gzipped translation text downloaded for one page view in an app with 6,000 messages per locale. All 25 locales in the main bundle: 2,500 KB. One file for the user's locale: 100 KB. The common namespace plus the current route's namespace: 15 KB. Server-rendered text with only client components' messages sent: about 4 KB.translation text per page view, gzippedall 25 locales in the main bundle2,500 KBone file for the user's locale100 KBcommon + this route's namespace15 KBserver-rendered, client parts onlyabout 4 KB
Example numbers for a large app, gzipped. Each step down is the same idea applied again: send only the text this user, on this page, will read.

Four ways to ship catalogs

Decision How do translations reach the browser?

Everything in the main bundle

Import every locale's JSON at the top of the app.

The runtime picks the user's locale from what is already loaded.

Pros

  • Simplest setup; no loading states.
  • Fine for two locales and a few hundred strings.

Cons

  • Every user downloads every language.
  • Each new locale makes the app slower for everyone.

One file per locale

Build one JSON or JS chunk per locale, like de.4f2a.json.

Load the user's locale before the first render.

Pros

  • Only one language crosses the network.
  • Hashed file names let the CDN cache each one for a year.

Cons

  • The file holds every screen's text, most of which the page never shows.
  • Any text change invalidates the whole file.

Per locale and per route

Split each locale into namespaces: common with the app shell, plus one per route or feature.

Load a route's namespace together with the route's code, in parallel.

Pros

  • The page downloads only the text it shows, often 10 to 30 KB.
  • A change to one feature's text invalidates only that file.

Cons

  • More files and a loading step on navigation.
  • A key used by two routes needs a clear home, usually common.

Rendered on the server

The server knows the locale from the URL and renders HTML with the text already in it.

Only client components' messages are sent to the browser; with React Server Components, server-only text never ships as a catalog.

Pros

  • The first paint is already translated, with no flash of keys.
  • The smallest client payload of all four.

Cons

  • Needs a server or build step per locale.
  • Client components' messages must be scoped and passed down.

What we pick: For a server-rendered app (see ssr), render text on the server and pass client components only the namespaces they use. For a single-page app, use per-locale, per-route namespaces with common loaded with the shell. Everything-in-the-bundle suits only tiny apps.

Avoid the extra round trip

The common mistake is a waterfall: the route's code loads, its component mounts, and only then asks for its namespace, in a second request. On a mobile network that is 100 to 300 ms of a spinner or, worse, raw keys on screen.

A sequence diagram with three columns: Router, Page, and CDN. Waterfall: the router gets checkout.js from the CDN, mounts the page, and only then does the page request de/checkout.json, costing one more round trip. Parallel: the router requests the code and the text together, gets both back in one round trip, and mounts the page already translated.RouterPageCDNwaterfallGET checkout.jscodemountGET de/checkout.json+1 round tripparallelGET code + text togetherboth, one round tripmount, translated
The waterfall puts a full round trip between code and text, and the page shows raw keys or a spinner meanwhile. Start both requests from the router, at the same moment.

Start both requests at once, from the router, not the component. Some teams import the route's namespace inside the route's chunk, so code and text share one file. That removes a request, but a text change then changes the code chunk's hash.

ts
const catalogs = {
  en: () => import("./locales/en/checkout.json"),
  de: () => import("./locales/de/checkout.json"),
  pl: () => import("./locales/pl/checkout.json"),
} as const;

type Locale = keyof typeof catalogs;

// In the route's loader: start code and text together
export async function loadCheckout(locale: Locale) {
  const [page, messages] = await Promise.all([
    import("./routes/checkout"),
    catalogs[locale](),
  ]);
  return { Page: page.default, messages: messages.default };
}
Each path is written out, so the bundler can see every file and make one small chunk per locale. A built path like a template literal would hide them.

Precompile messages

An ICU message must be parsed into a tree before it can be formatted, and the parser is code you ship. Both FormatJS and Lingui can compile catalogs at build time: FormatJS turns messages into a ready-made AST (the parsed tree), and Lingui turns them into compact JavaScript. The browser skips parsing, and you can leave the parser out of the bundle.

Server rendering and switching language

With server rendering, the HTML and the client must use the same messages, or hydration (React attaching to server HTML) finds different text and complains. The usual pattern is a provider at the top of the tree that receives the locale and the exact messages the server used, serialized into the page. next-intl's NextIntlClientProvider and react-intl's IntlProvider both work this way.

Switching language in place means loading the new catalogs before re-rendering, or the screen shows a mix of two languages for a moment. Many apps avoid this by navigating to the new locale's URL, like /de/checkout, which also gives each language its own indexable page. Either way, update lang on the html element. Locale URLs and SEO are covered in i18n-performance-seo.

Check your understanding · Exercise 5

A single-page app supports 20 locales. Users report raw keys flashing on screen when they open the settings page. What helps? Select all that apply.

Fallbacks and missing translations

Translations always lag behind code: a feature ships on Tuesday and the Japanese text arrives on Friday. A translator skips a key, or a vendor returns a message with a broken brace. The app needs an answer for each case, decided ahead of time, not discovered by a user.

The fallback chain

A fallback chain is the ordered list of catalogs the runtime tries for a key. The default rule walks up the locale tag and ends at the source locale: for a user in en-GB, try en-GB, then en. For fr-CA, try fr-CA, then fr, then the default en.

This lets regional catalogs stay tiny. The en-GB catalog holds only the messages that differ from en, like colour and Postcode; everything else falls through. The same works for pt-BR over pt, or de-AT over de.

A flowchart for t of cart.title with the locale fr-CA. Is the key in fr-CA? If yes, show the fr-CA text. If no, is it in fr? If yes, show the fr text. If no, is it in en, the source? If yes, show English and log the gap. If no, it is a code bug that CI should have failed, because a key missing from the source locale is not a translation delay.t("cart.title")locale: fr-CAin fr-CA?yesshow fr-CA textnoin fr?yesshow fr textnoin en (source)?yesshow English, log gapnobug: fail CImissing from the sourceis a code bug, not a delay
Three exits are normal: regional text, parent-language text, or English with a log line. The fourth exit, missing from the source, should never reach production.

When cutting the tag is wrong

Walking up the tag is a good default, not a law. Some cases need an explicit chain, which every major library can configure:

  • Chinese scripts. zh-HK (Hong Kong, written in Traditional characters) should fall back to zh-Hant, not to a zh catalog that is often written in Simplified characters. Many readers of one script find the other wrong, not just different.
  • Regional groups. es-MX and es-AR usually read better falling back to es-419 (Latin American Spanish) before es (often written for Spain).
  • Close languages. Norwegian nn (Nynorsk) users may prefer nb (Bokmål) over English when a message is missing. That is a product decision, made with local teams.

Picking the starting locale is a separate step called negotiation: matching the user's preferences (navigator.languages, Accept-Language, a saved setting, or the URL) against the locales you support. Use a real matcher, such as @formatjs/intl-localematcher, not string comparison (see i18n-performance-seo).

User's locale
Key
Chain rule
The fallback chain for cart.title with the locale fr-CA: fr-CA, missing; fr, Votre panier; en, not needed. The user sees Votre panier, from fr.chain for fr-CA (cut the tag)fr-CAmissingfrVotre panierennot neededVotre paniershown from fr
The runtime tries each catalog in order and stops at the first that has the key. Try zh-HK and es-MX with both chain rules: cutting the tag is a good default, but some languages need a chain you configure.

Missing keys: loud in development, graceful in production

A missing key means one of two things. If the key is missing from the source locale too, it is a developer bug: a typo or a deleted message. If it exists in the source but not in the user's locale, it is a translation delay. Treat them differently.

SituationIn development and CIIn production
Key missing from the source localeFail the build or the test; show a red marker in the UI.Should never reach production. If it does, show nothing and log an error.
Key missing in the user's localeShow a visible marker so testers notice; list it in a report.Fall back down the chain to the source message, and log the key and locale.
Message fails to parse or formatFail CI: the catalog is broken.Catch the error for that one message, use the source message instead, and log it.
Placeholder missing in a translationFail CI: a translation without {amount} hides the price.Should be caught before release; if not, it renders without the value.

Every library has a hook for this. FormatJS calls an onError handler you pass to its provider and falls back to the defaultMessage written next to the call. i18next has fallbackLng for the chain and a missingKeyHandler (with saveMissing) to report gaps. Send those reports to your logging with a low sample rate: one missing key on a popular page can fire millions of times a day.

Fall back whole messages, never parts

If the French message for a key is missing, show the whole English message. Never fill a missing branch of a French plural with English words. A complete sentence in a second language is readable; a sentence in two languages looks broken.

Raw keys like checkout.payButton are the worst fallback of all. They mean nothing to users, on screen or read aloud by a screen reader. The one place a raw key is useful is a debug mode for translators, where seeing keys helps them map text to the catalog.

Check your understanding · Exercise 6

A user's locale is pt-BR. The key profile.title is missing in pt-BR but exists in pt and en. The pt message has a syntax error. What should the user see? Pick one.

The translator workflow: from code to translators and back

Code is half the system. The other half is a loop next to development: new English goes out to translators, and finished text comes back into the build. Done by hand with spreadsheets and emailed JSON, this loop loses weeks; done well, it is almost fully automated.

A timeline over seven days for one new English string. On day 0 it is merged, extracted, and uploaded to the TMS within minutes, and a machine draft follows. Translators work from about day 0.6 to day 3.5, review takes day 3.5 to 4.5, the pull request and CI checks run until day 5, and it deploys at about day 5.3. Until then German users see the English fallback; after that they see German.days after the English merges01234567merge + extractupload to TMSmachine drafttranslatorsreviewPR + CI checksdeployGerman users seeEnglish (fallback)German
Only the translators' and reviewers' bars are people's work; the rest is automation that takes minutes to hours. The bottom row is why fallbacks matter: for most of the week, the new string is shown in English.

The pipeline, step by step

  1. Extract. A command such as formatjs extract, lingui extract, or i18next-parser scans the source and writes the source catalog. Nobody edits the English catalog by hand, so it always matches the code.
  2. Upload. On merge to the main branch, CI pushes new and changed source messages to a TMS (translation management system), such as Crowdin, Lokalise, Phrase, Smartling, or Transifex. Translators work there, with the source, notes, and history.
  3. Translate. Translators work in the TMS with a translation memory (earlier translations of similar sentences), a glossary (fixed terms like product names that must not be translated), and often a machine translation draft to edit.
  4. Review. A second linguist or local team checks high-impact text: checkout, legal, onboarding, marketing.
  5. Download. The TMS opens a pull request with the updated catalogs, or CI pulls them on a schedule.
  6. Validate. CI checks every message: it parses as ICU, uses the same placeholders and tags as the source, has other in every plural, and stays under any length limit. A failing check blocks the merge, not production.
  7. Ship. Catalogs go out with the next deploy. Some teams instead publish catalogs to a CDN at runtime (over-the-air updates), so text changes without a release; then a broken message reaches users directly, so the same checks must run before publishing.

Context is the translator's most important input

Translators usually see one message at a time, without the app. Book could be a noun or a verb; Free could mean no cost or not busy. Without context, even a good translator guesses.

  • Descriptions. Write one next to each message in code. FormatJS has a description field; Lingui has comment and context. Say where it shows, what kind of word it is, and what each placeholder holds.
  • Length limits. Put the maximum length in the description, or as metadata the TMS can enforce, for tab bars, buttons, and push titles.
  • Screenshots. Most TMSs can attach screenshots to keys. Teams generate them in Storybook or Playwright test runs and link each one to the keys it shows.
  • In-context editing. Some TMSs let translators edit text on a live staging copy of the app.

Pseudo-localization: test before any translator starts

Pseudo-localization generates a fake locale from the English catalog by machine. Each message is rewritten so it is still readable to an English speaker but looks foreign and longer: Go to checkout becomes something like [Ĝö ţö çĥéçķöûţ one two]. Placeholders and tags are left alone, so the message still works.

  • Accented letters reveal text that never went through the catalog: anything still in plain English on screen is hard-coded.
  • Padding by 30 to 40 percent shows which buttons, tabs, and table headers will overflow in German or Finnish.
  • Brackets at both ends reveal truncation: if the closing bracket is missing, the text was cut off.
  • A right-to-left pseudo-locale reverses direction to test mirrored layouts before Arabic or Hebrew exist. Android ships pseudo-locales named en-XA (accented) and ar-XB (right-to-left) for exactly this.
Two wireframes of the same cart screen. In English: the heading Your cart, a fixed-width button Go to checkout, a button Save for later, and a Sign out link. In the en-XA pseudo-locale: the heading reads [Ŷöûŕ çàŕţ one]; the fixed-width checkout button's text [Ĝö ţö çĥéçķöûţ one two] spills past its edge; the content-sized Save for later button grows to fit [Šàṽé ƒöŕ ļàţéŕ one two]; and Sign out is still plain English, which means it is hard-coded.enYour cartGo to checkoutSave for laterSign outen-XA (pseudo)[Ŷöûŕ çàŕţ one][Ĝö ţö çĥéçķöûţ one two][Šàṽé ƒöŕ ļàţéŕ one two]Sign outplain English: hard-coded
Pseudo-localization finds two bug classes before any translator starts: text that overflows a fixed size, and text that never went through the catalog.

Turn pseudo-localization on in development and in visual regression tests. It needs no translator and finds most layout and extraction bugs the day the code is written. FormatJS can generate pseudo-locales at compile time, and the plural lab earlier in this lesson has a toggle to try it.

When the English changes

Source text changes all the time, and the TMS marks translations of a changed message as outdated. What should users see until the new translation arrives? After a typo fix the old translation is still right, so keep showing it. If the meaning changed, like Delete becoming Archive, the old translation is wrong, and English is better.

The cleanest signal is the key itself: when the meaning changes, use a new key. The old translations stop matching, the new key falls back to English until translated, and nobody sees a wrong translation. Projects with fixed release dates, like GNOME, also use a string freeze: no new or changed English in the weeks before a release, so translators can catch up.

A state machine for one key in one locale. A new key is uploaded and moves to in translation. When translators finish it becomes translated. CI checks and a deploy make it shipped. If the English source changes, it becomes outdated and is re-queued to in translation. A note says that when the meaning changes, use a new key instead.newin translationtranslatedshippedoutdateduploaddoneCI + deployEnglish changedre-queuednew meaning?use a new key
A typo fix in English sends the key around the loop while the old translation stays usable. A change of meaning should start a new key at new, so no user sees a translation of the old meaning.

Check your understanding · Exercise 7

Which checks belong in CI for catalogs coming back from the TMS? Select all that apply.

Libraries, formats, and real products

You will rarely write an i18n runtime yourself. The useful skill is knowing where the main libraries differ and which fits a stack. All four below do catalogs, interpolation, plurals through Intl.PluralRules, rich text, and fallbacks.

QuestionFormatJS (react-intl)i18next (react-i18next)Linguinext-intl
Message syntaxICUIts own: {{name}} and plural keys like item_one; ICU via a pluginICUICU
How code names a messageAn id, often with defaultMessage and description beside itSemantic keys in namespacesSource text in code; ids hashed at build, or explicitSemantic keys in nested JSON namespaces
Rich textTag functions in valuesThe Trans componentThe Trans macro around JSXt.rich with tag functions
Extractionformatjs extracti18next-parser and similar toolslingui extractMessages are usually written straight into JSON
Build stepOptional: compile messages to an ASTNot requiredRequired: macros and compiled catalogsNot required
Best fitAny React app that wants standard ICUAny framework, many loading plugins, React NativeTeams who want readable source text and a small runtimeNext.js App Router, including server components

How to choose

  • Next.js App Router: next-intl. It renders text in server components with getTranslations and passes client components only the namespaces you choose.
  • Standard ICU in any React app: FormatJS. Its defaultMessage next to each call doubles as source text and last fallback.
  • Many frameworks sharing one setup: i18next. It runs in React, Vue, Angular, React Native, and Node, with plugins for loading and language detection.
  • Readable code and a small runtime: Lingui. You write English inside t and Trans macros, and the build compiles the catalogs.

Frameworks also ship their own answers. Angular's built-in i18n marks text in templates and builds one copy of the app per locale, so no catalog loads at runtime. Vue has vue-i18n; Android uses strings.xml with a plurals element, and Apple platforms use String Catalogs. Many TMSs read and write all of these formats, plus gettext PO and XLIFF, so web and mobile can share one translation memory.

How large products do it

  • Mozilla Firefox uses Fluent, a message format Mozilla designed, in which each locale can express grammar the source language does not have, much like ICU branches but with more room for the translator.
  • Meta open-sourced FBT, a framework where developers write source text inline in JSX and the build extracts it, with explicit markers for gender and number.
  • Wikipedia and MediaWiki are translated by volunteers on translatewiki.net, into hundreds of languages, with a documentation entry for each message to give translators context.

The pattern repeats: text out of code, one message per sentence, context for translators, machine-checked catalogs, and fake locales before real ones. The formats differ; the architecture does not.

Accessibility and performance angles

  • lang attributes. Set lang on html to the page's locale, and on any element whose text is in a different language, like a language switcher listing Deutsch and 日本語. Screen readers switch voice and pronunciation from it.
  • Accessible names are text too. aria-label, alt, and visually hidden text must go through the catalog. English labels on a translated page break it for screen reader users only, so they are easy to miss.
  • Fallback language is still a different language. When a message falls back to English inside a German page, screen readers read it with German pronunciation. For long fallback passages, some apps set lang on the element too.
  • Formatter cost. Creating an Intl formatter costs far more than calling it, so cache formatter objects. Libraries do this for you; hand-written helpers often do not.

Check your understanding · Exercise 8

Which statements about the libraries are accurate? Select all that apply.

Using it in a system design interview

Sometimes i18n is the question: "Our app must launch in 12 languages, design the frontend for it." More often it hides inside another question, like a global store or a travel booking flow. Mentioning it unprompted, briefly and with the right words, is a strong signal at mid and senior levels.

When to bring it up

  • In requirements. Ask: "Which markets and languages at launch, and later? Any right-to-left languages?" The answer decides whether i18n is a footnote or a design pillar.
  • In the architecture. Draw catalogs as static assets on the CDN next to JavaScript chunks, and a provider at the top of the tree.
  • In the API. The API returns codes and raw values (amounts in minor units with a currency, UTC timestamps), never preformatted or English text.
  • In optimizations. Per-locale, per-route catalogs; server-rendered text; precompiled messages.

A two-minute answer

A complete short answer covers five points, in this order, and leaves room for the interviewer to dig into any of them.

  1. Text lives in per-locale catalogs, split into namespaces by feature, with semantic keys and a description for each.
  2. Messages use ICU MessageFormat, so plurals, gender, and word order belong to the translator; plural categories come from CLDR through Intl.PluralRules.
  3. The client loads one locale, and only the namespaces of the current route, in parallel with the route's code; with SSR the text is already in the HTML.
  4. A fallback chain (fr-CA to fr to en) covers missing translations, which are logged, while missing source keys fail CI.
  5. Extraction, a TMS, screenshots for context, CI validation, and pseudo-localization make the loop with translators safe and mostly automatic.

How deep to go, by level

LevelWhat a strong answer shows
JuniorText out of components into catalogs, a t function, no string concatenation, and awareness that plurals differ by language.
MidICU plural and select, semantic keys and namespaces, loading one locale per route, a fallback chain, and the lang attribute.
SeniorCLDR categories by name, rich text without HTML injection, SSR and hydration with scoped messages, CI checks for placeholders and plurals, and the TMS workflow with context and pseudo-localization.
StaffThe migration plan for a large codebase (lint rule first, then feature by feature), ownership of English copy, over-the-air text updates and their risks, and shared translation memory across web and mobile.

Common follow-ups

  • "How would you add Arabic?" Mention six plural categories, then right-to-left layout with CSS logical properties (see rtl-layouts).
  • "Prices and dates?" Keep raw values and format them with Intl, inside messages or with formatters (see locale-formatting).
  • "How do search engines see each language?" One URL per locale, hreflang links, and server-rendered text (see i18n-performance-seo).
  • "Can marketing change copy without a deploy?" Over-the-air catalogs from a CDN, guarded by the same validation as the build.

Check your understanding · Exercise 9

In a design for a global marketplace, which statements would strengthen your answer? Select all that apply.

Summary

  • Keep all user-facing text, including aria-labels, alt text, and API error messages, in per-locale catalogs from day one; components know keys and values, never words.
  • Prefer semantic keys with a description for translators; one key per meaning, and never build keys at runtime.
  • Write one ICU message per sentence. plural, select, and selectordinal let each language express its own grammar; CLDR gives English two cardinal categories, Polish four, Arabic six.
  • Never concatenate translated fragments, and use tags inside messages for links and bold text instead of HTML.
  • Ship one locale, split into namespaces per route, loaded in parallel with the route's code, or render the text on the server.
  • Define a fallback chain such as en-GB to en, fall back whole messages, log missing translations, and fail CI on missing source keys or broken messages.
  • Automate the translator loop: extraction, a TMS with context and screenshots, CI checks on returned catalogs, and pseudo-localization before any translator starts.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

A product manager wants Arabic, Polish, and Japanese next quarter. The app has English strings hard-coded in 800 components. How do you plan it?

Question 2

Why is t("selected", { count }) + " " + t("files") a bug even when both pieces are translated correctly?

Question 3

How do you keep a 20-locale app fast?

Question 4

A user in fr-CA opens a page where one message has no French translation yet. What happens, and what should happen behind the scenes?

Question 5

Design asks for a sentence with a link in the middle. How do you implement it so it translates well and stays safe?

Practice it

These design problems lean on translating an app.

References

Read next