i18n and Localization
Purpose
Make the app translatable without scattering locale logic through the codebase. All user-facing text comes from message catalogs; locale is resolved once at the routing boundary; formatting (dates, numbers, plurals) goes through the i18n layer, never hand-rolled.
Universal — message catalogs, locale negotiation, ICU message format (plurals/gender/select), and RTL handling are framework-agnostic concerns; only the library API and routing integration differ.
Procedure
Choose the locale routing strategy
- Path prefix (
/en/about,/ko/about) — best for SEO, explicit, shareable. Default choice. - Domain / subdomain (
example.de,de.example.com) — strong regional signal, heavier ops - Cookie / header only (no URL change) — avoid for public pages (not crawlable, not shareable)
- Decision driver: if pages must rank per-locale in search → path prefix or domain, never cookie-only
- Negotiation: on first visit detect from
Accept-Language, redirect to the matched locale, persist the user's explicit choice in a cookie, and always have a default locale - Locale ≠ language:
en-USvsen-GB,pt-BRvspt-PTdiffer in formatting and spelling — key by the full locale where it matters
- Path prefix (
Centralize message catalogs
- One file per locale (
messages/en.json,messages/ko.json) - Namespace by feature, not by page, so keys survive refactors
- Never inline user-facing strings in components — all go through the
t()accessor
- One file per locale (
2b. Plan the translation workflow and missing-key behavior
- Strings reach translators through a process (a TMS like Crowdin / Lokalise / Phrase, or PR review) — decide it early, or new keys ship untranslated
- Missing-key behavior: fall back to the default locale, never render the raw key (
app.title) to users; in dev, surface missing keys loudly so they're caught before release
Split server vs client messages
- Don't ship every locale's full catalog to the client — send only the active locale, only the namespaces the client tree needs
- Server Components read messages directly; Client Components receive a scoped provider
Use ICU message format for non-trivial strings
- Plurals:
{count, plural, one {# item} other {# items}}— never string-concatenate count + noun - Plural categories are language-specific (CLDR): English has
one/other, but Arabic has six (zero/one/two/few/many/other) and Polish/Russian have complex rules — provide every category the target language needs, don't assume English's two - Never build a sentence from glued fragments (word order varies by language) — a translatable unit is a full sentence with named placeholders, not positional indexes
- Gender / select where the target language requires it
- Plurals:
Route all formatting through the i18n layer
- Dates / times: locale-aware formatter (respect timezone)
- Numbers / currency:
Intl.NumberFormatvia the library, never manualtoFixed+ symbol - Relative time ("3 days ago"): library formatter, not hand-rolled
Handle RTL and text expansion
- Set
dir="rtl"at the html level for RTL locales; use CSS logical properties (margin-inline-start, notmargin-left) — coordinate withresponsive-design - Mixed-direction text (a phone number, English brand, or code inside RTL) needs bidi isolation —
<bdi>ordir="auto"— or it renders garbled - Layouts must tolerate ~30% text expansion (German / Finnish) without truncation
- Set
Audit for hardcoded strings (validation loop)
- Grep for user-facing literals in JSX; for each, move to the catalog and replace with
t() - Pseudo-localization: run a fake accented + expanded locale (e.g.
[!! Ḗḓīŧ ṗřǿƒīŀḗ !!]) to catch both hardcoded strings (they stay un-accented) and text-expansion/truncation breakage — before real translations exist - Re-run until no untranslated literals remain in components
- Verify: switching locale changes every visible string (no source language leaking into the translated view)
- Grep for user-facing literals in JSX; for each, move to the catalog and replace with
Anti-patterns
| ❌ Anti-pattern | ✅ Correct |
|---|---|
<p>You have {count} items</p> |
ICU plural: t('items', { count }) → {count, plural, ...} |
'$' + price.toFixed(2) |
Intl.NumberFormat(locale, { style: 'currency', currency }) |
margin-left in a layout that must support RTL |
CSS logical property margin-inline-start |
| Cookie-only locale on a public marketing page | Path-prefix routing (/ko/...) for crawlability |
| Shipping all locales' catalogs to the client | Send only the active locale + needed namespaces |
Rendering a missing key as app.title |
Fall back to the default locale's string |
Assuming one/other plurals for every language |
Provide all CLDR categories the target needs |
Severity tiers
| Tier | Examples | Action SLA |
|---|---|---|
| Critical | Raw message keys (app.title) shown to users; source-language text leaking into a translated page on a public route |
Block release; fix immediately |
| Major | Hardcoded user-facing strings in components; plurals concatenated (count + noun) or missing CLDR categories the language needs; currency/date formatted by hand instead of Intl |
Fix this sprint |
| Minor | Missing dir/logical properties for an RTL locale on the roadmap; layout truncating under text expansion; cookie-only locale on a non-critical page |
Schedule within 2 sprints |
Completion Criteria
- Locale routing strategy chosen and documented (ADR if non-obvious); negotiation + default locale wired
- Hardcoded user-facing strings in components = 0 (verified via pseudo-localization)
- Plurals use ICU format with the CLDR categories each target language needs (no count + noun concatenation)
- Dates / numbers / currency formatted via
Intlthrough the i18n layer - Missing keys fall back to the default locale (no raw keys shown); surfaced in dev
- Switching locale changes 100% of visible strings
- RTL: CSS logical properties + bidi isolation for mixed text;
dirset per locale (if RTL on roadmap)
Output
- Message catalogs:
messages/<locale>.jsonnamespaced by feature - Routing config: locale-segmented routes (path prefix default) + middleware for negotiation
- ADR:
docs/adr/ADR-NNN-i18n-strategy.md(routing strategy + library choice) - Audit report (paste into PR): hardcoded strings found / migrated, locales covered, formatting calls routed through
Intl - Commit format:
feat(i18n): add <locale>/refactor(i18n): extract <feature> strings to catalog
Implementation
React + Next.js (default)
- Library:
next-intl(App Router-native — works in both Server and Client Components, middleware routing) - Routing:
next-intl/middlewarewithlocalePrefix('always'|'as-needed') + a[locale]route segment - Messages:
messages/<locale>.json;getTranslations()in Server Components,useTranslations()in Client Components - Formatting:
useFormatter()/getFormatter()(wrapsIntl.DateTimeFormat,Intl.NumberFormat) - Missing keys:
getMessageFallback/onError— fall back to the default locale in prod, throw/log in dev - Translation workflow: a TMS (Crowdin / Lokalise / Phrase) syncs
messages/*.json; pseudo-locale via a build step or a generated fake-locale catalog - SEO: emit
hreflangalternates + per-locale canonical (coordinate withseo-metadata)
Other stacks
- Vue / Nuxt:
@nuxtjs/i18n(routing + lazy catalogs built in);vue-i18ncore with$t+ ICU via@intlify/* - SvelteKit:
inlang/paraglide-js(compile-time, tree-shakable) orsvelte-i18n; routing via[lang]param + hooks - Angular: built-in
@angular/localize(compile-time) or@ngx-translate/core(runtime); locale viaLOCALE_ID - Universal: ICU MessageFormat is a standard used by all major libs;
Intl.*APIs are built into every browser/runtime; RTL via CSS logical properties is framework-agnostic
Related skills
seo-metadata— hreflang + per-locale canonical tags pair with i18n routingrender-strategy-decision— locale-segmented routes affect static vs dynamic choicedesign-system-construction— components must tolerate text expansion + RTL
Reference
- Key insight encoded: Resolve locale once at the routing boundary (middleware +
[locale]segment), then read messages from a centralized catalog — never inline strings or hand-roll plural/currency logic. Ship only the active locale's needed namespaces to the client; sending all catalogs is a silent bundle-bloat source. The i18n-specific QA technique is pseudo-localization (accented + expanded fake locale) — it surfaces hardcoded strings and truncation before real translations exist. Decide the translation workflow and missing-key fallback (default locale, never the raw key) up front, and remember plural categories are per-language (CLDR), not just English's one/other. - Caveats:
next-intlis the recommendation for App Router specifically;react-i18nextremains valid for non-Next React. Library choice should be an ADR, not assumed.