GAP·MAP

i18n and localization

frontend · System Designmiddlesenior~8 min read

covers the Intl API surface · ICU MessageFormat & plurals · RTL & CSS logical properties · locale negotiation & routing · keys, extraction & TMS · currency, time zones & locale data

[ middle depth ]

Stop building sentences out of fragments

The habit that breaks first is assembling a sentence from translated pieces at the call site:

const text = t("You have ") + count + t(" new ") +
  (count === 1 ? t("message") : t("messages"));

A translator who only sees "You have ", " new ", "message", and "messages" cannot move the number or the noun to where the target grammar needs them, because the sentence structure lives in your JavaScript, not in the string. The fix is one key per whole message, with the variable parts named inside it. That is what ICU MessageFormat gives you:

newMessages = You have {count, plural,
  one {# new message}
  other {# new messages}
}

# is replaced by the formatted number. one and other are plural categories, and the translator fills in the ones their language uses. Most i18n libraries (react-intl / FormatJS, i18next, LinguiJS) parse this syntax, so you write the ICU string and the library resolves the category at runtime.

Why count === 1 is a bug outside English

English has two forms, singular and plural, so a ternary on === 1 happens to work. Many languages do not.

Wrong "Every language has a singular and a plural, so I translate both strings and pick with === 1."

Right Polish sorts integers into three forms. One file is "1 plik" (the one category), 2 to 4 files are "pliki" (the few category, and it also covers 22, 23, 24), and most other integers are "plików" (the many category, e.g. 5 and 11). Arabic uses six categories. The categories a language needs come from CLDR, the Unicode locale data set, and Intl.PluralRules is the API that returns them:

new Intl.PluralRules("pl").select(2); // "few"
new Intl.PluralRules("pl").select(5); // "many"
new Intl.PluralRules("en").select(2); // "other"

An ICU plural block just writes those category names as branches. You never hard-code the digit, and you never assume which categories exist.

Let Intl format numbers, money, and dates

Number and date presentation is a pile of locale rules: which mark separates thousands, which separates the decimal, where the currency symbol sits, how many fraction digits a currency uses. Hand-building the string bakes in one locale's answer.

const price = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
}).format(1234.5); // "1.234,50 €"

new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" })
  .format(1234); // "¥1,234"  (yen has no minor unit, so no decimals)

German swaps the separators ("." for thousands, "," for the decimal) and moves the symbol after the amount. The Japanese yen has zero default fraction digits, so Intl.NumberFormat prints no decimal point. A hand-rolled "$" + n.toFixed(2) gets all of that wrong for most locales.

Dates work the same way. 12/03 is March 12 in the US and December 3 in most of Europe, so pass the locale and let Intl.DateTimeFormat decide:

const fmt = new Intl.DateTimeFormat("en-GB", { dateStyle: "long" });
fmt.format(new Date("2026-03-12")); // "12 March 2026"

Keep the raw values in storage (integer cents, an ISO instant) and format only when you display. Date and time-zone traps run deep; the module javascript-dates-time owns them.

RTL means logical properties, not left and right

Arabic, Hebrew, Persian, and Urdu read right to left. A layout built on margin-left, padding-right, and text-align: left stays physically pinned to those sides when the page flips, so labels and spacing land on the wrong edge.

Wrong "For Arabic I set direction: rtl and it mirrors the layout."

Right direction: rtl flips text and inline flow, but a property that names a physical side (margin-left) still means left. Use CSS logical properties, which resolve against the writing direction:

/* physical: always the left edge */
.card { margin-left: 1rem; text-align: left; }

/* logical: the start edge, left in LTR and right in RTL */
.card { margin-inline-start: 1rem; text-align: start; }

Set dir="rtl" (or dir="auto") on the root or the element, author spacing with margin-inline-*, padding-inline-*, inset-inline-*, and the same stylesheet serves both directions. You stop maintaining a hand-flipped RTL copy.

Picking the user's language

The browser sends an Accept-Language header, a priority list like da, en-gb;q=0.8, en;q=0.7. It is a fine first guess and a bad final authority: it reflects browser and OS settings that most people never change, and it says nothing about the choice a returning user already made.

Read the header to pick a sensible default on the first visit. After that, an explicit choice (a language switcher) should be stored and take priority, so the next request does not overwrite it. Putting the locale in the URL (/de/pricing, or de.example.com) makes each language a distinct, cacheable address. Do not derive language from IP geolocation: it tracks where the request comes from, not what the user reads.

[ senior depth ]

Plural categories are grammar classes, not number checks

CLDR assigns every number to one of six cardinal categories: zero, one, two, few, many, other. Only other is guaranteed to exist; a language uses the subset its grammar needs. English cardinals use two (one, other), Polish uses four, Arabic uses all six. The category is computed from operands of the number, not from its value alone: n (the value), i (integer part), v (count of visible decimal digits), w, f, t. Russian's rule for one is v = 0 and i % 10 = 1 and i % 100 != 11, so 1 and 21 are one but 11 is many.

Two consequences bite in review:

one is not =1. In an ICU message, =1 is an exact-value match; one is the grammatical singular class, which in Russian also matches 21, 31, 41 (Polish one is exactly 1; there 21 is many). Use =0/=1 only when you want a literal special case ("no messages"), and let one/few/many carry the grammar.

Decimals change the category. 1.0 files and 1 file differ: v is 1 in the first, so Czech and others route them to different forms. If you pluralize a formatted decimal, feed Intl.PluralRules the same rounding you display.

const pr = new Intl.PluralRules("pl");
["1", "2", "5", "22"].map(Number).map((n) => pr.select(n));
// ["one", "few", "many", "few"]

For "1st / 2nd / 3rd", the type is different again: new Intl.PluralRules("en", { type: "ordinal" }) returns one/two/few/other, which is why ICU has a separate selectordinal. A plural block cannot produce ordinal suffixes.

The Intl surface replaces code that only ever worked in English

Each constructor stands in for a hand-rolled routine that silently assumed English or ASCII.

  • Intl.Collator for sorting. Array.prototype.sort() orders by UTF-16 code unit, so uppercase sorts before lowercase and accented or non-Latin letters land wrong. A collator sorts per locale, and the same letter can differ by locale: ä sorts near a in German but after z in Swedish. Reuse one collator instance (collator.compare) rather than calling localeCompare per pair; it resolves the locale once.
  • Intl.Segmenter for counting or slicing text. String.length and [...str] count UTF-16 units or code points, not user-perceived characters, so an emoji or a base letter plus a combining mark overcounts, and languages without spaces (Japanese, Chinese, Thai, Khmer) defeat split(" "). Segment at grapheme granularity for character limits, word for word counts.
  • Intl.RelativeTimeFormat for "3 days ago". rtf.format(-1, "day") gives "yesterday" under numeric: "auto", translated per locale, which no string template can do across languages.
  • Intl.ListFormat for "A, B, and C", whose separators and final conjunction vary by locale.

formatToParts() on the number and date formatters returns the pieces (integer, group, decimal, currency, literal) so you can style or re-order without parsing a formatted string, which would be locale-fragile.

Locale negotiation is a precedence chain, not a header read

Resolve the locale by precedence, first source that answers wins:

URL path/subdomain/de/ or de.site.com, cacheable and shareablesaved user choicecookie or profile, survives across visitsAccept-Languagebrowser default, only the first-visit guesssite defaultlast resort, never inferred from IP alone
fig · Locale precedence, first match wins

Accept-Language is a quality-weighted priority list (da, en-gb;q=0.8, en;q=0.7), useful as the initial default. It reflects browser configuration many users never change, so it must not outrank a choice the user actually made. Persist that choice and, for anything crawlable, reflect the locale in the URL so each language is a distinct cacheable resource. The hreflang annotations that tell search engines about those alternates belong to the seo-and-metadata module; this one owns how the app chooses and routes the locale.

When you pass a locale list to any Intl constructor, the runtime negotiates against what it has, controlled by localeMatcher: "lookup" is the BCP 47 fallback algorithm (strip subtags until a match), "best fit" (the default) lets the engine pick something at least as suitable, so results can differ across runtimes. Read the resolved locale back with .resolvedOptions().locale instead of assuming you got what you asked for.

Formatting traps live at the boundary

Currency is the sharp one. A currency's default fraction digits are part of its definition, not the locale: JPY and KRW use zero, most currencies use two, and a few use three. Intl.NumberFormat with style: "currency" applies that automatically, so never fix decimals with toFixed(2). Keep money as an integer in the smallest unit plus a currency code, and format only for display. The precision rules for that stored integer belong to js-numbers-precision.

Wrong "Store the price already formatted, like "$19.90", so the UI just prints it."

Right A formatted string encodes one locale and one currency and cannot be reformatted, compared, or summed. Store the canonical value and run Intl.NumberFormat at the edge.

Time zones have the same shape: an instant plus a target zone is correct, a pre-formatted local string is lossy. Always pass an explicit timeZone to Intl.DateTimeFormat rather than trusting the server's zone. The offset-versus-named-zone details are javascript-dates-time.

Locale data is not free. The CLDR and ICU tables behind these APIs are large, so ship message catalogs and any polyfill locale data per locale and load them on demand (a dynamic import keyed by the active locale), not bundled into the main entry. On the server, the runtime needs the actual data present: Node ships full ICU by default now, but a small-icu build only has English, so Intl.DateTimeFormat("es", ...) silently returns English or M01-style output. Verify full ICU (or supply NODE_ICU_DATA) as part of the deploy, because the failure is quiet and locale-specific.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

gapmap pro

You've read the i18n and localization notes. An interviewer will ask you to prove them.

Know you're ready. Don't hope.

The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:

  • Mock interviews on your topics

    An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.

  • Voice test interviews

    The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.

  • A plan to your date

    Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.

  • A mentor inside every lesson

    Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.

Pro $20/mo · Pro+ $50/mo · every study note on the site stays free

more System Design

was this useful?