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).
First: Check for an L10N.md Charter
Before writing or translating any string, check the project root for L10N.md. When it exists, it is the project's authoritative localization charter — read it in full and let it override the generic guidance in this skill wherever the two conflict. Derive from it:
- Project context — the domain the product operates in (financial, technological, medical, …). Domain dictates terminology register and tone: "credit" means one thing in a banking app and another in a game, and a payments product cannot afford a casual mistranslation of a regulated term.
- What to translate directly and what not — the charter's do-not-translate list: brand and product names, trademarks, legal or regulated terms, technical identifiers. Never translate an entry on that list, even when a natural target-language equivalent exists.
- Branding and glossary — approved per-locale renderings of recurring product terms. Reuse them verbatim; inventing a second translation for an established term fragments the product's voice across locales.
Only the root L10N.md is read. Some projects nest per-area charters (server/L10N.md) or per-locale overrides (L10N/es.md); those are not consulted here, so anything that must always apply belongs in the root file.
When L10N.md does not exist, skip this step entirely — proceed with the generic guidance below, and do not create the file unasked.
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
- If
L10N.mdexists at the project root: translations follow its project context, do-not-translate list, and glossary - 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