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 checkscount === 1is 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
Openwith 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?
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.
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
- 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-BRfor Brazilian Portuguese,zh-Hantfor Chinese in traditional characters. The app picks it from the URL, a saved setting, or theAccept-Languageheader. - 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, andsettings. - A translate function. Usually called
torformatMessage. Components call it with a key and values, liket("cart.summary", { count: 3 }). - 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.
// 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>
);
}
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?
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
Openis a verb on a button and an adjective on a status badge. German needsÖffnenfor one andGeöffnetfor 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 aswitchor 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.
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,234in English,1.234in German). - Exact match:
=0 {...}or=1 {...}inside a plural matches that exact number and wins over the category. Teams use=0for 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.otheris required in bothpluralandselect. - 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.
{
"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 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.
| Language | Cardinal categories | Which numbers go where |
|---|---|---|
| Japanese, Chinese, Korean | other | Every number: nouns do not change with count. |
| English, German | one, other | one is exactly 1; everything else, including 0 and 1.5, is other. |
| French | one, many, other | one is 0, 1, and fractions below 2 like 1.5. many is for exact millions like 1,000,000 (1 000 000 de fichiers). |
| Polish | one, few, many, other | one 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. |
| Russian | one, few, many, other | one is 1, 21, 31, 101 (not 11); few is 2 to 4, 22 to 24; many is 0, 5 to 20, 25 to 30. |
| Arabic | zero, one, two, few, many, other | zero 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.
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.
// 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>
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.
/terms. Unmapped tags never become HTML.// 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" /> }} />
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, orotherwhen unknown) and useselect. Never infer gender from a name. - Lists.
Alice, Bob, and Caroluses an Oxford comma in American English,Alice, Bob und Carolin German, and different separators in Japanese. UseIntl.ListFormat(seelocale-formatting), notjoin(", "). - Words inside other words. Don't insert a translated noun into another message, like
Delete {itemType}?withitemTypeas 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.
Four ways to ship catalogs
Decision How do translations reach the browser?
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.
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.
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 };
}
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.
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 tozh-Hant, not to azhcatalog that is often written in Simplified characters. Many readers of one script find the other wrong, not just different. - Regional groups.
es-MXandes-ARusually read better falling back toes-419(Latin American Spanish) beforees(often written for Spain). - Close languages. Norwegian
nn(Nynorsk) users may prefernb(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).
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.
| Situation | In development and CI | In production |
|---|---|---|
| Key missing from the source locale | Fail 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 locale | Show 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 format | Fail CI: the catalog is broken. | Catch the error for that one message, use the source message instead, and log it. |
| Placeholder missing in a translation | Fail 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.
The pipeline, step by step
- Extract. A command such as
formatjs extract,lingui extract, ori18next-parserscans the source and writes the source catalog. Nobody edits the English catalog by hand, so it always matches the code. - 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.
- 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.
- Review. A second linguist or local team checks high-impact text: checkout, legal, onboarding, marketing.
- Download. The TMS opens a pull request with the updated catalogs, or CI pulls them on a schedule.
- Validate. CI checks every message: it parses as ICU, uses the same placeholders and tags as the source, has
otherin every plural, and stays under any length limit. A failing check blocks the merge, not production. - 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
descriptionfield; Lingui hascommentandcontext. 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) andar-XB(right-to-left) for exactly this.
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.
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.
| Question | FormatJS (react-intl) | i18next (react-i18next) | Lingui | next-intl |
|---|---|---|---|---|
| Message syntax | ICU | Its own: {{name}} and plural keys like item_one; ICU via a plugin | ICU | ICU |
| How code names a message | An id, often with defaultMessage and description beside it | Semantic keys in namespaces | Source text in code; ids hashed at build, or explicit | Semantic keys in nested JSON namespaces |
| Rich text | Tag functions in values | The Trans component | The Trans macro around JSX | t.rich with tag functions |
| Extraction | formatjs extract | i18next-parser and similar tools | lingui extract | Messages are usually written straight into JSON |
| Build step | Optional: compile messages to an AST | Not required | Required: macros and compiled catalogs | Not required |
| Best fit | Any React app that wants standard ICU | Any framework, many loading plugins, React Native | Teams who want readable source text and a small runtime | Next.js App Router, including server components |
How to choose
- Next.js App Router: next-intl. It renders text in server components with
getTranslationsand passes client components only the namespaces you choose. - Standard ICU in any React app: FormatJS. Its
defaultMessagenext 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
tandTransmacros, 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
langattributes. Setlangonhtmlto the page's locale, and on any element whose text is in a different language, like a language switcher listingDeutschand日本語. 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
langon the element too. - Formatter cost. Creating an
Intlformatter 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.
- Text lives in per-locale catalogs, split into namespaces by feature, with semantic keys and a description for each.
- Messages use ICU MessageFormat, so plurals, gender, and word order belong to the translator; plural categories come from CLDR through
Intl.PluralRules. - 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.
- A fallback chain (
fr-CAtofrtoen) covers missing translations, which are logged, while missing source keys fail CI. - 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
| Level | What a strong answer shows |
|---|---|
| Junior | Text out of components into catalogs, a t function, no string concatenation, and awareness that plurals differ by language. |
| Mid | ICU plural and select, semantic keys and namespaces, loading one locale per route, a fallback chain, and the lang attribute. |
| Senior | CLDR 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. |
| Staff | The 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 (seelocale-formatting). - "How do search engines see each language?" One URL per locale,
hreflanglinks, and server-rendered text (seei18n-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,alttext, 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, andselectordinallet 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-GBtoen, 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?
First stop the problem growing: pick a library with ICU messages (next-intl, FormatJS, or i18next with ICU), add a provider, and turn on a lint rule that flags literal text in JSX and in aria-label, alt, and title. Then move strings into namespaced catalogs with semantic keys, one feature at a time, starting with the highest-traffic flows. Turn on a pseudo-locale in development: anything still in plain English is not extracted yet, and anything that overflows will overflow in Polish.
In parallel, set up the pipeline: extraction in CI, upload to a TMS with descriptions and screenshots, validation of returned catalogs (ICU parses, placeholders match, other present), and a pull request back. Load one locale per user and per route. Plan Arabic as its own track: six plural categories in messages and a right-to-left layout, which means moving CSS to logical properties.
Question 2
Why is t("selected", { count }) + " " + t("files") a bug even when both pieces are translated correctly?
The grammar of a sentence spans the pieces. The noun's form depends on the number (Polish has four forms, Arabic six), the word order differs between languages, and a translator who sees two fragments can't see the sentence they form.
One ICU message, {count, plural, one {# file selected} other {# files selected}}, fixes all three: each locale writes the branches its language needs, in its own word order, and the code only passes count.
Question 3
How do you keep a 20-locale app fast?
Never ship more than one locale. Split each locale into a common namespace loaded with the shell and one namespace per route, loaded in parallel with the route's code so text adds no round trip. Give catalog files content hashes and cache them as immutable on the CDN.
With server rendering, render text on the server and pass client components only the messages they use; with React Server Components, server-only text never ships as a catalog at all. Precompile ICU messages so the browser skips parsing and the parser stays out of the bundle.
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?
The runtime walks the fallback chain: fr-CA, then fr, then the source locale en. The user sees the whole English message for that one string, never a raw key, never a sentence mixing two languages.
Behind the scenes the gap is logged with the key and locale, sampled so a popular page doesn't flood the logs, and the TMS already has the key queued because extraction pushed it on merge. If the key were missing from en as well, that would be a code bug and CI should have failed.
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?
One message with a tag around the link text, like Read our <link>privacy policy</link> first., and code that maps link to an anchor element (FormatJS tag functions, next-intl t.rich, or react-i18next Trans). The translator moves the tag where their grammar needs it.
Never render translations as HTML with dangerouslySetInnerHTML: translations come from outside the team, and only the tags you mapped should become elements. Everything else renders as plain text.
Practice it
These design problems lean on translating an app.
References
- ICU User Guide: Formatting Messages The MessageFormat syntax, plural and select arguments, and quoting rules from the source.
- CLDR Language Plural Rules Every language's plural categories with example numbers: check this before writing a plural lab or test.
- MDN: Intl.PluralRules The browser API every i18n library uses to pick a plural branch.
- FormatJS: Message syntax ICU syntax as react-intl implements it, including rich text tags.
- i18next documentation Namespaces, fallback languages, plurals, and missing-key handling.
- Lingui documentation Source-text messages with macros, extraction, and compiled catalogs.
- W3C: Text size in translation Why short strings grow the most when translated, and what it means for layout.
Read next
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it. - Locales, routing, and SEOHow a multilingual site picks a language, gives every language its own URL, tells search engines about the copies with hreflang, renders and caches each locale on the server, keeps translation bytes small, and copes with German text that runs 30% longer.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.