Internationalization (i18n)
Overview
Designing software to adapt to languages/regions without code changes (i18n), then adapting it per-locale (l10n). Covers translation architecture, ICU pluralization, Intl-based formatting, RTL/bidi, and libraries (i18next, react-intl/FormatJS, gettext).
The Rules That Prevent Rework
-
Never concatenate translated fragments. Word order, gender agreement, and grammar differ per language. Use one full-sentence key with named placeholders; the translator controls order.
// Wrong — impossible to translate; order is baked into code t("You have") + " " + count + " " + t("new messages"); // Right — one message, interpolation + plural inside it t("inbox.newMessages", { count }); // "{count, plural, one {# new message} other {# new messages}}" -
Pluralize with CLDR categories, never
if (count === 1). English has 2 forms; Russian/Polish 3–4; Arabic 6 (zero one two few many other). Which categories a language uses is defined by CLDR — use ICU MessageFormat /Intl.PluralRules, not hand-rolled logic. -
Format with
Intl, never by hand. Decimal/grouping separators, currency placement, date order, and calendars are locale data you will get wrong manually (1,234.56en-US vs1.234,56de-DE vs1 234,56fr-FR). -
A locale is language + region.
en-USvsen-GB:12/25/2024vs25/12/2024,colorvscolour, different currency. Store and resolve full BCP-47 tags; fall back language-only → default. -
Store timestamps in UTC, format at display time in the user's timezone/locale. Never store locale-formatted strings.
-
RTL is not just
direction. Use CSS logical properties, isolate bidirectional runs, and mirror directional icons.
Architecture
src/locales/{en,de,ar}/{common,products,errors}.json # one dir per locale, split by namespace
src/i18n/config.ts
- Externalize every user-facing string. Hardcoded text is the #1 bug — catch it with pseudolocalization (below) and lint rules.
- Namespaces split catalogs by feature/route so you lazy-load only what a view needs (huge for bundle size on large apps).
- Fallback chain:
de-AT → de → en. Configure it; missing keys should degrade, not render blank or crash. - Keys are descriptive and hierarchical (
products.card.addToCart), never the source English text (brittle: fixing a typo breaks every locale's lookup). Add context when a word is ambiguous (button.closevsproximity.close).
interface LocaleConfig {
code: string; // "en-US" (BCP-47)
direction: "ltr" | "rtl";
currency: string; // ISO 4217, "USD"
}
Translation File Formats
JSON (i18next / react-intl) — most common. i18next plural keys use a suffix per CLDR category:
{
"welcome": "Welcome, {{name}}!",
"items_one": "{{count}} item",
"items_other": "{{count}} items",
"price": "Price: {{price, currency}}"
}
⚠ i18next v4+ derives the suffix (_one, _other, _few, _many, _zero, _two) from Intl.PluralRules for the active language — you must provide every category the language needs (Arabic needs all six), and you must pass { count } for suffix selection to fire.
ICU MessageFormat (react-intl/FormatJS, and i18next via a plugin) puts logic inside the string:
{count, plural, =0 {No items} one {# item} other {# items}}
{gender, select, male {He} female {She} other {They}} liked your post.
{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
=0is an exact match (checked before category);one/other/etc. are CLDR categories;#prints the number.- Nest plural inside select for gender-correct counts. Don't over-nest — hand it to translators as one message.
PO/gettext (Python/PHP/Ruby): source string is the key, plurals via msgid_plural + Plural-Forms header. xgettext extracts, translators use Poedit.
Formatting with Intl
One class over the standard Intl objects covers most needs. Cache formatter instances — constructing Intl.*Format is expensive; reuse per (locale, options).
const nf = new Intl.NumberFormat("de-DE");
nf.format(1234567.89); // "1.234.567,89"
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(99.9); // "99,90 €"
new Intl.NumberFormat("en", { notation: "compact" }).format(12000);// "12K"
new Intl.DateTimeFormat("ja-JP", { dateStyle: "long" }).format(d); // "2024年12月19日"
new Intl.RelativeTimeFormat("de", { numeric: "auto" }).format(-1, "day"); // "gestern"
new Intl.ListFormat("en", { type: "conjunction" }).format(["A","B","C"]); // "A, B, and C"
new Intl.PluralRules("ar").select(3); // "few" → pick the message key
⚠ Gotchas:
dateStyle/timeStylecan't be combined with individual component options (year,month, …) in oneDateTimeFormat— pick one style. PasstimeZonefor server-consistent output; default is the runtime's zone.- Currency: pass an explicit ISO 4217 code; the locale controls placement/symbol, not which currency.
narrowSymbolfor "$" over "US$". - Prefer
dateStylepresets over custom component lists — presets already encode locale order (MDY vs DMY vs YMD); custom lists you assemble often leak English order.
RTL & Bidirectional Text
Use logical properties so one stylesheet serves both directions — no [dir=rtl] overrides:
.card {
margin-inline-start: 1rem; /* left in LTR, right in RTL */
padding-inline: 1rem;
border-inline-start: 3px solid;
text-align: start;
}
.icon:dir(rtl) { transform: scaleX(-1); } /* mirror only directional icons: arrows, chevrons, back — NOT logos/checkmarks */
- Set
<html dir="rtl" lang="ar">; flexbox/grid flow followsdirautomatically. - Bidi isolation: user-generated or opposite-direction text embedded in a sentence (an Arabic name in English UI, a phone number) can reorder surrounding punctuation. Wrap it in
<bdi>…</bdi>, orunicode-bidi: isolate, or the Unicode isolates…(FSI/PDI) in plain strings. Numbers next to RTL text are the classic breakage. - RTL testing must use real translated RTL content, not mirrored Lorem Ipsum — bidi bugs only surface with genuine strings.
Locale Detection & Switching
Precedence: explicit URL/param → cookie/stored pref → Accept-Language (parse q weights, match against supported set) → default. Validate against your supported list before use.
function parseAcceptLanguage(header: string): string[] {
return header.split(",")
.map(part => { const [code, q] = part.trim().split(";q="); return { code, q: parseFloat(q) || 1 }; })
.sort((a, b) => b.q - a.q)
.map(x => x.code);
}
On switch, update three things: the i18n instance, document.documentElement.lang, and .dir. Persist the choice. SPA switch should not require a reload.
Sorting & Search
String comparison is locale-dependent — never .sort() raw for user-visible lists (default sorts by code point: Z < a, ä after z). Use Intl.Collator:
const collator = new Intl.Collator("de", { sensitivity: "base", numeric: true });
list.sort(collator.compare); // locale-correct; numeric:true gives "file2" < "file10"
sensitivity: "base" ignores case/accents (good for search/dedup); numeric: true for natural number ordering. Reuse the collator instance.
Libraries
i18next (framework-agnostic, plugin-rich):
i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init({
fallbackLng: "en",
supportedLngs: ["en", "de", "fr", "ar"],
ns: ["common", "products"], defaultNS: "common",
backend: { loadPath: "/locales/{{lng}}/{{ns}}.json" }, // lazy per-namespace
detection: { order: ["querystring", "cookie", "navigator"], caches: ["cookie"] },
interpolation: { escapeValue: false }, // React already escapes; escapeValue:true double-encodes
});
const { t, i18n } = useTranslation(["products", "common"]);
t("products:price", { price });
t("common:items", { count }); // plural via count
// Interpolation inside markup — use <Trans>, do NOT build strings from JSX children
<Trans i18nKey="products:promo" values={{ name }}>Check out <strong>{{ name }}</strong> today!</Trans>
⚠ escapeValue: false in React is correct (React escapes); leaving it true double-encodes. Outside React, keep escaping on to avoid XSS from interpolated values.
react-intl / FormatJS (ICU-native, standards-aligned):
<IntlProvider locale={locale} messages={messages[locale]} defaultLocale="en">…</IntlProvider>
const intl = useIntl();
intl.formatMessage({ id: "app.items" }, { count: 5 }); // ICU plural in the message
<FormattedMessage id="app.greeting" values={{ name }} />
intl.formatNumber(1234.56, { style: "currency", currency: "EUR" });
Ships @formatjs/cli to extract messages and precompile ICU ASTs (faster runtime). Wire onError so missing IDs are caught in CI, not shipped blank.
Python gettext:
t = gettext.translation("messages", localedir, languages=["de"], fallback=True)
_ = t.gettext; ngettext = t.ngettext
_("Welcome, %(name)s!") % {"name": user}
ngettext("%(count)d item", "%(count)d items", count) % {"count": count}
Testing & QA
Pseudolocalization — the highest-ROI check. Transform the default locale into accented, expanded text to catch two bug classes at once:
"Add to Cart" → "[!! Àdd tö Çårt ~~~~ ]"
- Untransformed on screen ⇒ a hardcoded string bypassing i18n.
- Padding (+30–40%, mimicking German/Finnish) ⇒ truncation/overflow before real translations exist.
Also test: longest language (German/Finnish) for overflow; a real RTL locale for layout+bidi; that number/date/currency render per locale (not just English); missing-key fallback path.
Extraction tooling (don't hand-roll AST walkers): i18next-parser for i18next catalogs, @formatjs/cli extract for react-intl, xgettext/Babel for gettext. Run in CI to fail on new untranslated keys and prune dead ones.
Verify Before Done
- [ ] Zero hardcoded user-facing strings (pseudoloc pass is clean)
- [ ] No concatenated sentence fragments; each message is one key with named placeholders
- [ ] Plurals use ICU/
PluralRulescategories (every category the language needs), notcount === 1 - [ ] All numbers/dates/currencies/lists via
Intl(formatters cached), not manual formatting - [ ] Timestamps stored UTC; formatted at display in user tz/locale
- [ ] Fallback chain configured; missing keys degrade gracefully (no blanks/crashes)
- [ ] RTL: logical CSS properties,
dir+langon<html>, bidi isolation on embedded runs, directional icons mirrored - [ ] User-visible lists sorted via
Intl.Collator, not raw.sort() - [ ] Locales lazy-loaded by namespace; switch updates i18n +
lang+dirwithout reload - [ ] Interpolation escaping correct for the runtime (React:
escapeValue:false; server: escape on) - [ ] Verified in ≥2 locales incl. one long-word and one RTL, with real translated content