| name | i18n-localization-setup |
| description | Externalizes user-facing text into message catalogs keyed by stable IDs and wires locale-correct rendering — ICU MessageFormat plurals/gender/select, named-placeholder interpolation, Intl/CLDR number/date/list/relative-time formatting, RTL/bidi via logical CSS, and an extract→translate→compile pipeline with pseudo-localization. |
| when_to_use | Making a product support multiple languages/locales, or auditing existing i18n — hardcoded UI strings, sentence concatenation, English-only `if(n===1)` plurals, missing RTL, locale-blind number/date formatting, or wiring i18next/react-intl/gettext/Rails i18n/Fluent. Distinct from style-responsive-tailwind (visual layout) and audit-accessibility-wcag (a11y conformance — i18n only owns translatable a11y *attribute text*). |
When to Use
Reach for this when text must render correctly in more than one language/locale, not just look right:
- "Add Spanish/Arabic/Japanese — what's the right way to externalize strings?"
- "Our plurals break in Polish/Russian" or "we do
count === 1 ? 'item' : 'items' everywhere"
- "Dates show as
6/15/2026 for everyone" / numbers use . for thousands in de-DE
- "Arabic/Hebrew layout is broken — everything's still left-to-right"
- "Translators can't reorder words — we concatenate
'Deleted ' + n + ' files'"
- "Set up the extraction pipeline: extract → PO/XLIFF/JSON → translate → compile" + catch missing keys before ship
- Auditing an app that's "already i18n'd" for the traps below
NOT this skill:
- Visual responsive layout, breakpoints, container sizing → style-responsive-tailwind (i18n owns logical CSS props +
dir, not the design system)
- WCAG conformance, screen-reader semantics, contrast → audit-accessibility-wcag (i18n only owns making
aria-label/alt/title translatable)
hreflang, localized URLs, sitemap per-locale, canonical → audit-technical-seo
- Validating/parsing user-entered locale data (phone, postal) → build-form-validation
- Wrapping a single component's copy as you build it → build-react-component (use this skill when standing up the catalog system)
- UTC storage, DST, IANA conversion math behind a displayed timestamp → datetime-timezone-correctness (i18n only formats the instant per locale; it doesn't compute it)
Steps
-
Externalize every user-facing string into a catalog keyed by a stable ID — kill concatenation. A string is translatable if a human ever reads it: labels, buttons, errors, emails, alt/aria-label/title/placeholder, <title>, push/toast text. Key by semantic ID, never by English source (English changes → key shouldn't). Co-locate by feature: checkout.cart.empty, not string_447.
{ "checkout.items": "{count, plural, one {# item} other {# items}}",
"profile.greeting": "Welcome back, {name}!" }
Never build sentences from fragments. t('deleted') + ' ' + n + ' ' + t('files') is untranslatable — word order, plural agreement, and gender all vary by language. One key = one whole sentence.
-
Pluralize with ICU MessageFormat / CLDR categories — never if (n === 1). English has 2 forms; Arabic has 6 (zero/one/two/few/many/other), Polish/Russian have 4. Provide every category the target locale's CLDR rules require; other is the mandatory fallback. Same mechanism for gender/choice via select. Use # for the count (auto-formatted per locale), not {count} re-interpolated.
| Need | ICU construct | Anti-pattern it replaces |
|---|
| Count agreement | {n, plural, one {…} few {…} many {…} other {…}} | n === 1 ? 'x' : 'xs' |
| Ordinals (1st/2nd) | {n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} | string-suffix hacks |
| Gender / enum | {g, select, female {…} male {…} other {…}} | branching in code, concatenating |
Common Errors
count === 1 ? x : xs. Breaks every language with ≠2 plural forms (Arabic, Polish, Russian, Welsh). Use ICU plural with CLDR categories.
- Sentence concatenation (
t('sent') + name + t('a_msg')). Word order/agreement/gender vary; translators can't fix it. One key = one full sentence with named placeholders.
- Keying by English source text. Editing the copy silently orphans the translation. Key by stable semantic ID.
- Hand-formatted numbers/dates (
'$' + n.toFixed(2), MM/DD/YYYY). Wrong separators/order/currency per locale. Use Intl.NumberFormat/DateTimeFormat with an explicit locale.
- Conflating locale with currency/timezone. A
de user can pay in USD in America/New_York. Format with the user's locale but the transaction's currency and the event's timezone; store UTC + ISO currency code.
- Physical CSS (
margin-left, float: right). Layout breaks in RTL. Use logical properties + dir.
- No bidi isolation. An RTL name/number injected into LTR text reorders adjacent punctuation/brackets. Wrap unknown-direction content in
<bdi>/unicode-bidi: isolate.
- Forgetting non-
textContent text. alt, aria-label, title, placeholder, <title>, email subjects, validation messages are all translatable — and untranslated aria-label regresses a11y.
- No length budget. German/Finnish run ~35% longer than English; pseudo-loc padding exposes truncation/overflow before translators do.
- Locale-blind sort/case. JS
.sort() is code-point order (Ä after Z); Turkish i↔İ/ı breaks toUpperCase(). Use Intl.Collator(locale) for sorting and toLocaleUpperCase(locale) for case.
- Inventing
Intl.LocaleMatcher. No such global exists — locale matching is the option on constructors or a library (). Don't string-compare BCP-47 tags.
Verify
- No hardcoded strings: lint (
eslint-plugin-formatjs, i18next no-literal rule) reports zero user-facing literals outside the catalog.
- Pseudo-loc pass: run UI in
en-XA — every visible string is accented+bracketed (no bare English = no missed key), nothing truncates or overflows, no concatenated fragments appear.
- Plural matrix: render the count message at
n = 0,1,2,5,11,100 in en, pl (4 forms), and ar (6 forms); each picks the CLDR-correct category. if(n===1) cannot pass this.
- Reordering: a target locale that reverses placeholder order renders correctly (proves named, not positional, interpolation).
- Formatting: the same number/date/currency/list renders per-locale separators/order (
1,234.5↔1.234,5, 06/15↔15/06, currency symbol placement) — assert against Intl golden strings.
- RTL: load
ar/he → <html dir="rtl">, layout mirrors via logical props, directional icons flip, bidi-isolated names don't scramble punctuation.
- Missing-key gate: delete a key from a non-source catalog → CI fails (or falls back to source) — it must never render a raw key like
checkout.items to a user.
- Negotiation:
Accept-Language: fr-CA with only fr available resolves to fr (not default/404) via a real matcher; switching locale at runtime updates messages and lang/dir.
- Sort/case: a localized list sorts via
Intl.Collator(locale) (e.g. Swedish å/ä/ö last); Turkish case round-trips with toLocaleUpperCase('tr').
Done = zero hardcoded user-facing strings, pseudo-loc clean, the plural matrix passes for a 4-form and a 6-form locale, RTL renders with logical CSS + bidi isolation, all formatting goes through Intl with explicit locale, locale negotiation uses a real BCP-47 matcher, and CI fails on any missing key or unknown ICU variable.