# I18N Localization

> Set up or audit internationalization — message extraction, locale routing strategy, server/client message split, ICU plurals, date/number/currency formatting, RTL, missing-key fallback, pseudo-localization testing. Use when adding a second locale, when hardcoded strings accumulate, or before going multi-region. Not for emitting hreflang/per-locale canonical tags (use seo-metadata) or deciding static vs dynamic rendering of locale routes (use render-strategy-decision).

- Skill: `jaykim88/i18n-localization` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/i18n-localization`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/i18n-localization/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/i18n-localization

---


# 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

1. **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-US` vs `en-GB`, `pt-BR` vs `pt-PT` differ in formatting and spelling — key by the full locale where it matters

2. **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

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

3. **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

4. **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

5. **Route all formatting through the i18n layer**
   - Dates / times: locale-aware formatter (respect timezone)
   - Numbers / currency: `Intl.NumberFormat` via the library, never manual `toFixed` + symbol
   - Relative time ("3 days ago"): library formatter, not hand-rolled

6. **Handle RTL and text expansion**
   - Set `dir="rtl"` at the html level for RTL locales; use CSS logical properties (`margin-inline-start`, not `margin-left`) — coordinate with `responsive-design`
   - Mixed-direction text (a phone number, English brand, or code inside RTL) needs bidi isolation — `<bdi>` or `dir="auto"` — or it renders garbled
   - Layouts must tolerate ~30% text expansion (German / Finnish) without truncation

7. **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)

## 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 `Intl` through 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; `dir` set per locale (if RTL on roadmap)

## Output
- **Message catalogs**: `messages/<locale>.json` namespaced 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/middleware` with `localePrefix` (`'always'` | `'as-needed'`) + a `[locale]` route segment
- Messages: `messages/<locale>.json`; `getTranslations()` in Server Components, `useTranslations()` in Client Components
- Formatting: `useFormatter()` / `getFormatter()` (wraps `Intl.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 `hreflang` alternates + per-locale canonical (coordinate with `seo-metadata`)

### Other stacks
- **Vue / Nuxt**: `@nuxtjs/i18n` (routing + lazy catalogs built in); `vue-i18n` core with `$t` + ICU via `@intlify/*`
- **SvelteKit**: `inlang/paraglide-js` (compile-time, tree-shakable) or `svelte-i18n`; routing via `[lang]` param + hooks
- **Angular**: built-in `@angular/localize` (compile-time) or `@ngx-translate/core` (runtime); locale via `LOCALE_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 routing
- `render-strategy-decision` — locale-segmented routes affect static vs dynamic choice
- `design-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-intl` is the recommendation for App Router specifically; `react-i18next` remains valid for non-Next React. Library choice should be an ADR, not assumed.

