Skip to content

Internationalization architecture

Localization is architecture: whole messages per locale, plural rules per language, Intl for every format, and one locale carried by the URL.

Predict first

Before you read: predict

The Arabic exam page shows the time left with this catalog message, formatted for the locale ar. The branches read, in order: no minute left · one minute left · two minutes left · # minutes left (three spellings). During one exam the banner shows 2, then 13, then 103 minutes left.

{minutes, plural,
  zero {لم تبق أي دقيقة}
  one {بقيت دقيقة واحدة}
  two {بقيت دقيقتان}
  few {بقيت # دقائق}
  many {بقيت # دقيقة}
  other {بقيت # دقيقة}}

Wrong or E → FULL. A correct tap with a vague why → TARGETED on steps 3–5. FAST needs cldr-category and last-two-digits.

Which branch renders for each count, in that order?

Pick one

The 30-second version

Every UI string is a whole message in a per-locale catalog. ICU plural picks a branch by the locale's CLDR category: English has one and other; Arabic has six, read from the last two digits: few for 3–10, many for 11–99. Intl formats numbers, dates and lists. The URL carries the locale; missing keys fall back region, language, default.

Localization is architecture: whole messages per locale, plural rules per language, Intl for every format, and one locale carried by the URL.

How it works

Step 1 of 14

Catalogs. Each UI string is a keyed message in one catalog per locale; code asks for the key, and translators edit only the catalogs.
In the kitchen
  • one set of picture cards for every session → one key per message, shared by all locales (step 1)

Analogy: A touring storyteller's picture cards

The selected image shows a storyteller laying wordless picture cards out in two arrangements: a row in front of her and a different layout beyond it. The pictures stay the same; each language's telling orders them its own way.

At the storytelling table In the app
one set of picture cards for every session one key per message, shared by all locales (step 1)
each teller's script, written as whole sentences, lays the cards in that language's order whole messages with placeholders; translators choose the order (step 2)
a teller swapping English words one at a time keeps the English order concatenated fragments (step 2)
the script's phrasing for "one goat", "two goats", "a few goats", "many goats" ICU plural branches by category (steps 3–5)
prices and dates read out the way the audience reads them Intl formatting (steps 8–10)
a scene with no Mexican Spanish line uses the Spanish line, then the English original the fallback chain (step 12)
the session a family booked at the desk sets the language; a guess from their accent applies only before booking the URL carries the locale; Accept-Language suggests (step 13)

In real terms: the code names a message and passes values; the locale's catalog, plural rule and formatters decide every word, digit and separator.

Breaks down when: a teller can improvise a phrasing for an odd count; a catalog cannot, so a missing branch falls to other. Nor does the room have time zones (step 9).

Contrasting case: a word-by-word subtitle machine that swaps each English word for a French one. It keeps the English order and the English singular/plural rule, which is what string concatenation does.

Examples, run for real

4.1 EXH-2020: the Arabic start button

Arabic imperatives change with the person addressed; English ones do not. The Arabic catalog held one fixed string, which addresses a man, so every candidate saw it. MR !2320 gives the Arabic message a select on the form of address chosen at check-in. The /ar/exam/… route that selects the Arabic catalog also sets <html lang="ar" dir="rtl">.

// before (EXH-2020), the Arabic catalog: "start the exam", said to a man
start: 'ابدأ الاختبار'
// after (MR !2320), formatted with @messageformat/core for 'ar'; the English catalog keeps 'Start the exam'
start: '{address, select, feminine {ابدئي الاختبار} masculine {ابدأ الاختبار} other {ابدأ الاختبار}}'
ar address=feminine  · before "ابدأ الاختبار" · after "ابدئي الاختبار"
ar address=masculine · before "ابدأ الاختبار" · after "ابدأ الاختبار"
en address=feminine  · "Start the exam"

Glosses: "ابدأ الاختبار" is "start the exam" said to a man, and "ابدئي الاختبار" the same said to a woman. Each locale's message carries only the arguments its own grammar needs (steps 2 and 7).

4.2 One order, four locales

The same amount, instant and list, formatted for four locales with an explicit timeZone.

en-US America/New_York number "1,234,567.5" · date "3/5/26, 4:15 PM" · either "A, B, or C"
de-DE Europe/Berlin    number "1.234.567,5" · date "05.03.26, 22:15" · either "A, B oder C"
ar-SA Asia/Riyadh      number "١٬٢٣٤٬٥٦٧٫٥" · date "٦/٣/٢٠٢٦، ١٢:١٥ ص" · either "A أو B أو C"
ja-JP Asia/Tokyo       number "1,234,567.5" · date "2026/03/06 6:15" · either "A、B、またはC"

Separators, digits, date order and the word for "or" all come from the locale, and the time zone moves the hour and even the day (steps 8–10).

4.3 Negotiation and the fallback chain

A request matched by lookup, then a Mexican Spanish page for which the app ships no regional catalog.

match(['de-CH', 'it'], ['en', 'de', 'it'], 'en', { algorithm: 'lookup' }) -> de
i18next lng es-MX, fallbackLng en: chain es-MX -> es -> en
t('cart') -> Carrito
t('pay') -> Pagar
t('help') -> Help
t('returns') -> returns

Lookup trims de-CH to de (step 11). i18next walks es-MX, es, then en, and shows the key itself when no catalog has it (step 12).

Picture it

A grey-haired storyteller in a green shirt lays wordless picture cards on a long table: a row of four in front of her and a different arrangement of six beyond it.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

The cart badge is built as below for en, fr and ar. In French, an empty cart reads "Vous avez 0 articles dans votre panier", and the Arabic badge reads wrongly for most counts. The translators then sent the forms in the second block (the Arabic ones read: your cart is empty; one product; two products; # products).

en | one: You have # item in your cart | other: You have # items in your cart
fr | one: Vous avez # article dans votre panier | other: Vous avez # articles dans votre panier
ar | zero: سلتك فارغة | one: في سلتك منتج واحد | two: في سلتك منتجان | few: في سلتك # منتجات | many: في سلتك # منتجا | other: في سلتك # منتج

Which change shows the translators' form for every count from 0 to 120 in all three languages?

Code for this question
const words = {  en: { youHave: 'You have', item: 'item', items: 'items', inCart: 'in your cart' },  fr: { youHave: 'Vous avez', item: 'article', items: 'articles', inCart: 'dans votre panier' },  ar: { youHave: 'لديك', item: 'منتج', items: 'منتجات', inCart: 'في سلتك' },};const badge = (locale, n) => `${words[locale].youHave} ${n} ${n === 1 ? words[locale].item : words[locale].items} ${words[locale].inCart}`;
Pick one

Question 2 of 8

The checkout's server renders a flash sale's end into the page with the code below. The server process runs with TZ=Europe/Paris. A shopper in Riyadh (Asia/Riyadh) opens the page. On that date Paris is at UTC+2, Riyadh at UTC+3 and London at UTC+1.

What does the server-rendered text say?

Code for this question
const ends = new Date('2026-10-20T22:30:00Z');const text = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeStyle: 'short' }).format(ends);
Pick one

Question 3 of 8

The Egyptian Arabic checkout (ar-EG) shows the total, 1234.5 Egyptian pounds (EGP), with Arabic-Indic digits. A shopper chose "Western digits" in her profile, so her total should read 1,234.50 beside the Arabic currency label ج.م..

Which formatter gives that?

Pick one

Question 4 of 8

The checkout ships three catalogs and picks one per request with @formatjs/intl-localematcher. A shopper's browser asks for Saudi Arabic first, then French.

Which catalog loads?

Code for this question
match(['ar-SA', 'fr'], ['en', 'fr', 'ar-EG'], 'en', { algorithm: 'lookup' });
Pick one

Question 5 of 8

The order confirmation names the items shipped, each already translated, and t('and') returns the locale's word for "and". The copy editors want: US English Charger, Case, and Cable, British English Charger, Case and Cable, French Chargeur, Étui et Câble, Arabic شاحن وحافظة وكابل (charger and case and cable).

Which code produces all four lines?

Code for this question
const names = { en: ['Charger', 'Case', 'Cable'], fr: ['Chargeur', 'Étui', 'Câble'], ar: ['شاحن', 'حافظة', 'كابل'] };const and = { en: 'and', fr: 'et', ar: 'و' };   // what t('and') returns
Pick one

Question 6 of 8

The store serves each language at its own path: /en/checkout, /fr/checkout and /ar/checkout. A shopper in Riyadh, whose browser sends Accept-Language: ar-SA, opens a link a friend shared: /fr/checkout.

What should the server do?

Pick one

Question 7 of 8

The checkout configures i18next as below. The France page uses lng: 'fr-FR', and the Arabic page lng: 'ar', which shows Delivery address in English until its translation arrives, as intended. On the Québec page the email label reads Courriel, but the Pay button and the address label are in English.

Which is the bug?

Code for this question
i18next.init({  lng: 'fr-CA',  fallbackLng: 'en',  resources: {    'fr-CA': { translation: { email: 'Courriel' } },    'fr-FR': { translation: { email: 'E-mail', pay: 'Payer', address: 'Adresse de livraison' } },    ar: { translation: { email: 'البريد الإلكتروني', pay: 'ادفع' } },    en: { translation: { email: 'Email', pay: 'Pay', address: 'Delivery address' } },  },});
Pick one

Question 8 of 8

The proctor console formats each candidate's flag summary with the pinned @messageformat/core, passing the stored gender unchanged. Four candidates have 2 flags each, and their records store gender as 'female', 'Female', 'nonbinary' and null.

What are the four summaries, in that order?

Code for this question
const summary = new MessageFormat('en').compile(  '{gender, select, female {She has} male {He has} other {They have}} {n, plural, one {# flag} other {# flags}}');summary({ gender: c.gender, n: c.flags });
Pick one

Go deeper

Pro tips
  1. Pseudo-localize before translating. Replace every catalog string with an accented, bracketed copy ([Ýöû ĥåṽé…]) and run the app; any text outside brackets was never in a catalog. (Not executed here; a build-step practice.)
  2. Canonicalize tags before you match them. Intl.getCanonicalLocales(['EN-gb', 'ar-sa']) returns properly cased tags, so a stored EN-gb compares equal to en-GB. Verify: it printed en-GB, ar-SA.
Source

Checked against the tools and docs as of 2026-10-01.