Installs into .claude/skills of the current project.
Are you the author of I18n?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/kouroshez-i18n)
---
name: i18n
description: Internationalize and localize software — externalized message catalogs, ICU MessageFormat for plurals/gender/select, locale-aware formatting of dates/numbers/currency, RTL/bidi layout, Unicode correctness, content negotiation, and the translation pipeline. Use when extracting hardcoded UI strings, choosing an i18n library (react-i18next/FormatJS/next-intl/vue-i18n/gettext), handling plural rules across languages, formatting per-locale dates/currency, supporting RTL scripts, designing the locale-resolution chain, or wiring a translation workflow. Boundary vs a11y and frontend-fundamentals — a11y owns assistive-tech access (semantics, screen readers, keyboard, contrast) and frontend-fundamentals owns generic UI state/structure; this skill owns the language-and-locale dimension — message catalogs, pluralization/grammar, locale-aware formatting, bidi/RTL, and Unicode handling — concerns that exist even for a fully accessible single-language UI. Defers per-locale visual tokens to frontend-design.
tier: cross-cutting
domain: [frontend, backend]
last_reviewed: "2026-06-14"
---
# Internationalization & Localization — One Codebase, Every Locale
A practical guide to building software that adapts to language, region, and script without forking the code per market. Stack-agnostic; recipes target react-i18next / FormatJS (ICU) / next-intl / vue-i18n on the client and ICU / gettext on the server. The distinction that frames everything: **i18n** (internationalization) is the engineering to *make* software localizable; **l10n** (localization) is the per-locale *content and adaptation* that engineering enables.
## When to Use This Skill
- Extracting hardcoded UI strings into a message catalog for the first time.
- Choosing an i18n library and message format (ICU MessageFormat vs gettext).
- Handling plurals/gender/grammar that differ by language (not every language has "1 vs many").
- Formatting dates, numbers, currency, and lists per locale.
- Supporting right-to-left (Arabic, Hebrew) and bidirectional text.
- Designing the locale-resolution chain (URL / header / cookie / user preference).
- Wiring the translator workflow — extraction, a translation-management system, re-import.
Skip when: the only concern is screen-reader/keyboard access for a single language — that is a11y. This skill is the language-and-locale dimension; the two compose (a localized UI must still be accessible).
## Rule Zero — Never Concatenate Translated Strings
The defining beginner mistake: `t("Hello") + " " + name + t("!")` or building a sentence from fragments. Word order, agreement, and punctuation differ per language; concatenation hardcodes English grammar into every locale.
```js
// WRONG — assumes English word order, breaks in most languages
const msg = t("You have") + " " + count + " " + t("new messages");
// RIGHT — one externalized message with named placeholders; the translator owns the whole sentence
t("inbox.unread", { count });
// catalog (ICU): "You have {count, plural, one {# new message} other {# new messages}}"
```
Every user-visible string is one complete, externalized message with named interpolation placeholders. The translator receives the full sentence and reorders freely.
## ICU MessageFormat — Plurals, Gender, Select
English has two plural forms (1 / many). Arabic has six; Polish and Russian have three with non-obvious rules; Japanese and Chinese have one. **Hardcoding `count === 1 ? singular : plural` is wrong for most of the world.** ICU MessageFormat encodes the CLDR plural rules so each locale's translation picks the right form:
```
{count, plural,
=0 {No items}
one {# item}
few {# items}
many {# items}
other {# items}}
```
- **`plural`** selects by the locale's CLDR category (`zero/one/two/few/many/other`); the translator fills the categories their language actually uses.
- **`select`** handles gender / arbitrary enums: `{gender, select, male {He} female {She} other {They}}`.
- **Nested** plural-in-select handles "He has 1 message / She has 3 messages" as one message.
- Use the platform `Intl.PluralRules` / an ICU-aware library; never reimplement plural logic in app code.
## Locale-Aware Formatting — Use `Intl`, Never Hand-Roll
Dates, numbers, and currency are formatted by the *locale*, not by a global format string.
```js
new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(date); // "14 June 2026" / "2026年6月14日"
new Intl.NumberFormat(locale).format(1234567.89); // "1,234,567.89" / "1.234.567,89"
new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
new Intl.ListFormat(locale, { type: "conjunction" }).format(items); // "A, B, and C" / "A、B、C"
```
- **Decimal/thousands separators flip** between locales (`1,234.56` vs `1.234,56`) — never format numbers with string ops.
- **Currency is a data attribute, not a locale attribute.** A French user can see USD; pass the currency code explicitly, let `Intl` place the symbol per locale.
- **Always store/transmit timestamps in UTC (ISO 8601);** format to the user's timezone only at display time. Storing local time is a recurring DST bug.
- **Translatable text is data, code formats it** — keep format logic out of the catalog.
## RTL / Bidi and Unicode Correctness
- **Right-to-left layout** (Arabic, Hebrew, Persian) mirrors the whole UI. Use CSS logical properties (`margin-inline-start`, not `margin-left`) and set `dir="rtl"` on `<html>` — then layout flips for free. Physical left/right hardcodes LTR.
- **Bidi text** (an English word inside an Arabic sentence) needs Unicode isolation (`Intl` / bidi control chars) or interpolated values render in the wrong order.
- **Unicode discipline**: normalize to NFC on input; a "character" the user sees may be multiple code points (combining marks, emoji ZWJ sequences) — count grapheme clusters, not code units, for length limits. Compare strings with locale-aware collation (`Intl.Collator`), not raw byte order, for sorting.
- **Text expands.** German runs ~30% longer than English; CJK is shorter but taller. Design flexible layouts; never size a button to the English string.
## Text Normalization — UTC-at-rest, For Strings
The same failure shape as a wrong timestamp: nothing raises, the value stores
and renders fine, and weeks later a login fails or a search returns nothing.
Two strings that **look identical** are unequal because they are different code
points. Arabic-script languages (Persian, Arabic, Urdu) carry three distinct
confusable classes:
| Class | Confusables | Where it bites |
|---|---|---|
| **Digits** | `۱۲۳` (Persian) · `١٢٣` (Arabic-Indic) · `123` (ASCII) | a phone number or amount typed on a Persian keyboard; `int()` raises or the lookup misses |
| **Letters** | `ی` U+06CC vs `ي` U+064A · `ک` U+06A9 vs `ك` U+0643 | text pasted from Arabic sources — searching `علی` misses a row stored as `علي` |
| **Invisible** | ZWNJ U+200C (`میرود`) · bidi marks (U+200E/F, U+202A-E) | copy-paste drags them in; the string is 1-3 code points longer than it looks |
Plus the generic NFC/NFD split: macOS returns filenames decomposed (NFD), Linux
stores composed (NFC), so a Persian filename created on a Mac is a different
string on the server.
**The contract — identical in shape to UTC-at-rest:**
1. **Normalize at the boundary** (form handler, API deserializer, file walker).
2. **Store the normalized form**; that is what indexes, uniqueness constraints
and comparisons see.
3. **Keep the raw input in a separate field** only when you must echo it back
verbatim — never compare against it.
4. **Normalize the query too.** Normalizing only on write leaves every search
broken in the other direction.
```python
import re, unicodedata
_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
_LETTERS = str.maketrans({"ي": "ی", "ك": "ک", "ة": "ه", "ۀ": "ه"})
_INVISIBLE = re.compile("[\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]")
def normalize_fa(text: str, *, keep_zwnj: bool = True) -> str:
text = unicodedata.normalize("NFC", text)
text = _INVISIBLE.sub("", text)
if not keep_zwnj: # drop for keys/slugs, keep for display
text = text.replace("\u200c", "")
return text.translate(_LETTERS).translate(_DIGITS).strip()
```
`keep_zwnj` is the one real judgment call: ZWNJ is **semantic** in Persian
(`میرود` is not `میرود`), so strip it for a comparison key and keep it in the
stored display text. In TypeScript the same shape is
`s.normalize('NFC').replace(...)`; `Intl.Collator('fa', {sensitivity:'base'})`
handles locale-aware *sorting*, which normalization does not replace.
**Do not case-fold with a locale.** Turkish maps `I` to `ı`, so a locale-aware
`toLowerCase` on a username comparison is a security bug. Use Python
`str.casefold()` / JS `toLowerCase()` without a locale argument for identity
comparisons.
## The Locale Resolution Chain & Pipeline
- **Resolve locale** in a defined precedence: explicit user setting → URL/path (`/de/...`, best for SEO) → `Accept-Language` header → cookie → default. Pick one order, document it, fall back gracefully to a default locale on any miss.
- **Lazy-load catalogs** per locale (and per route/namespace) — shipping every translation to every user bloats the bundle. Load the active locale's catalog only.
- **Catalogs are the contract with translators.** Extract strings to catalog files (JSON/PO/XLIFF), push to a translation-management system (Crowdin, Lokalise, Tolgee), re-import. Never hand-edit translated files in code; never let untranslated keys ship as raw key names — fall back to the source locale.
- **Pseudolocalization in CI** (`[!!! ḺöREM !!!]`) surfaces hardcoded strings and truncation before a human translator is paid.
## Anti-Patterns (reject in review, fix on sight)
- **Concatenating translated fragments** — hardcodes English grammar everywhere.
- **`count === 1 ? x : y` pluralization** — wrong for most languages; use ICU plural + CLDR.
- **Hardcoded date/number format strings** — `MM/DD/YYYY` is wrong outside the US; use `Intl`.
- **Physical CSS (`left`/`right`, `padding-left`)** — breaks RTL; use logical properties.
- **Hardcoded UI strings** left in components — nothing to translate; extract to the catalog.
- **Storing local time instead of UTC** — DST and timezone bugs.
- **Treating currency as derivable from locale** — pass the currency code explicitly.
- **Byte-length limits on user text** — breaks multibyte scripts; count grapheme clusters.
- **Shipping all locales' catalogs to every user** — bundle bloat; lazy-load the active locale.
- **Translator-facing keys named `string1`/`btn2`** — give descriptive keys + context for accurate translation.
## Tools per surface (2026 defaults)
| Need | Default | Alternatives |
|---|---|---|
| React i18n | react-i18next, FormatJS (react-intl) | next-intl (Next.js), Lingui |
| Vue i18n | vue-i18n | nuxt/i18n |
| Message format | ICU MessageFormat | gettext (PO) for server/legacy |
| Locale formatting | platform `Intl` (`DateTimeFormat`/`NumberFormat`/`PluralRules`/`ListFormat`) | Luxon, date-fns-tz |
| Server-side i18n | ICU4J / ICU4C, gettext, Python `babel` | — |
| Translation management | Crowdin, Lokalise, Tolgee | Phrase, Weblate |
| QA | pseudolocalization, screenshot diff per locale | — |
## Pairs With
- **a11y** — owns assistive-technology access; this skill owns the language/locale dimension. A localized UI must still be accessible — set `lang`/`dir` so screen readers announce correctly (the seam between the two).
- **frontend-fundamentals** — owns generic UI structure/state; this skill adds the localization layer over it.
- **frontend-design** — owns per-locale visual tokens and the flexible layouts that absorb text expansion.
- **api-design** — `Accept-Language` content negotiation and locale-aware error messages cross the API boundary.
- **technical-writing** — source strings written for clarity and translatability (short, context-rich, no idioms) start in the catalog.
## See also
- Unicode CLDR — plural rules, locale data, collation.
- ICU MessageFormat / `Intl` (MDN) — the canonical formatting APIs.
- W3C Internationalization — bidi, language tags (BCP 47), text layout.
- *Going Global with JavaScript and Globalize.js* and the FormatJS docs — practical ICU patterns.