Translation & localization
You are making a product speak more than one language correctly — not word-for-word. The job splits in two, and confusing them is the root cause of "we translated everything but the dates are still American."
- i18n (internationalization) = engineering the code so it can hold any locale: no hardcoded strings, externalized catalogs, locale-aware formatting, locale routing. Done once.
- l10n (localization) = the per-locale content and cultural formatting: the actual translations, plural rules, date/number/currency conventions, RTL layout. Done per language, repeatedly.
Sequence matters: do i18n first. Teams that skip it end up retrofitting string extraction under a launch deadline, which is when concatenation and count === 1 bugs ship.
Route out fast
| If the task is really about… | Go to |
|---|---|
| Inventing the brand's tone/word bank in one language | ../brand-voice/SKILL.md |
| App Router structure, RSC, data fetching (non-locale) | ../nextjs/SKILL.md |
| hreflang strategy, per-country keyword research, geo ranking | ../seo-geo/SKILL.md |
| Authoring net-new source copy (landing, article) | ../content-engine/SKILL.md |
General a11y semantics beyond lang/dir/bidi |
../accessibility/SKILL.md |
This skill emits the lang/dir/alternate plumbing and only touches routing where it is locale-specific. It does not author source copy or invent voice — it moves an existing voice across languages.
Decision: where are you?
| Symptom | Enter |
|---|---|
| Hardcoded strings live in JSX/components | Phase 1 — internationalize the code |
| Plurals/gender are wrong in some language | Phase 2 — ICU MessageFormat |
| Dates/numbers/currency look wrong per locale | Locale-aware formatting (Intl.*) |
| Layout breaks, text overflows, Arabic mirrored wrong | Phase 3 routing + RTL & bidi |
| "We have the strings, we need them translated" | Phase 4 — translation workflow |
Phase 1 — internationalize the code
Externalize every user-facing string to a catalog. A string in JSX cannot be translated, pseudolocalized, or counted as missing. Move it to a message file keyed by a stable, namespaced ID.
Key on intent, not on the English text. checkout.payButton survives a copy change; "Pay now" as the key breaks every translation the moment marketing edits the English.
{
"checkout": {
"payButton": "Pay now",
"itemsInCart": "{count, plural, one {# item} other {# items}}"
}
}
Library choice — pick once:
| Stack / need | Use | Why |
|---|---|---|
| Next.js App Router | next-intl |
Request-scoped i18n/request.ts + defineRouting(), RSC-native, localePrefix strategies |
| Cross-stack / existing i18next ecosystem | next-i18next / i18next |
Shared catalogs across React Native, backend, plugins |
| Tiny app, few strings, no plural complexity | Intl only |
No dependency; you still get correct formatting |
Never concatenate translated fragments. Word order is not universal — building a sentence from pieces produces garbage in languages that reorder subject/object/verb.
// Bad — three fragments the translator can never reorder
<p>{t("youHave")} {count} {t("messages")}</p>
// Good — one message, named/numeric ICU argument
<p>{t("inbox.summary", { count })}</p>
// "inbox.summary": "{count, plural, one {You have # message} other {You have # messages}}"
Configure a fallback locale. A missing key in de should resolve to en, never render the raw key or blank. Set the default/source locale as the fallback at config time.
Phase 2 — ICU MessageFormat correctly
ICU MessageFormat is the cross-library standard for plurals and select, powered by CLDR plural categories. Use it for plurals and gender only — keep date/number formatting out (next section).
CLDR defines up to six plural categories. English uses two; you cannot assume singular/plural is enough.
| Category | Example languages |
|---|---|
one, other |
English, German, Spanish |
one, few, many, other |
Russian, Polish, Czech |
zero, one, two, few, many, other |
Arabic (all six) |
other only |
Chinese, Japanese, Korean |
// Bad — correct only for English-like 2-form languages
const label = count === 1 ? "item" : "items";
// Good — ICU resolves the right CLDR category per locale
// "cart.items": "{count, plural, one {# item} other {# items}}"
// Russian catalog supplies one/few/many/other; the call site never changes.
Every plural, selectordinal, and select block REQUIRES an other clause — it is the mandatory fallback; without it the message is invalid and the formatter throws.
{gender, select, female {She replied} male {He replied} other {They replied}}
{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place
Wrap the formatter in try/catch and fall back to the key. A malformed ICU string from a translator throws at runtime — returning the key keeps the UI alive while you log the error.
function format(key: string, args?: Record<string, unknown>) {
try {
return formatter.format(messages[key], args);
} catch (err) {
console.error(`ICU format failed for "${key}"`, err);
return key; // visible, non-crashing fallback
}
}
See references/icu-cookbook.md for nested plural+select, escaping ' and {, and per-language plural-category tables.
Locale-aware formatting
Dates, numbers, and currency belong to Intl.*, not inside ICU messages and never string-concatenated. These read CLDR data, so de-DE renders 1.000,50 while en-US renders 1,000.50 — for free.
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1000.5);
// "1.000,50 €"
new Intl.DateTimeFormat("ja-JP", { dateStyle: "long" }).format(new Date());
// "2026年6月2日"
new Intl.RelativeTimeFormat("es", { numeric: "auto" }).format(-1, "day");
// "ayer"
new Intl.ListFormat("en", { type: "conjunction" }).format(["a", "b", "c"]);
// "a, b, and c"
new Intl.PluralRules("ar").select(3); // "few" — drives non-ICU plural logic
Rule: if you typed a /, ,, ., $, or % into a translated string, you have a bug. Format with Intl.
Phase 3 — locale routing & rendering
For Next.js App Router with next-intl, route locale through a [locale] segment. Defer all non-locale framework detail to ../nextjs/SKILL.md.
// routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "es", "ar"],
defaultLocale: "en",
localePrefix: "as-needed", // 'always' | 'as-needed' | 'never'
});
localePrefix: always (/en/..., /es/...), as-needed (default locale unprefixed), or never (locale via cookie/header only). Pick based on your SEO needs — defer the ranking call to ../seo-geo/SKILL.md.
Add generateStaticParams on the [locale] segment so each locale renders statically. Set lang and dir on <html> — dir is what flips the layout.
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default function RootLayout({ children, params: { locale } }) {
const dir = locale === "ar" || locale === "he" ? "rtl" : "ltr";
return <html lang={locale} dir={dir}>{children}</html>;
}
RTL & bidi
Set direction with the HTML dir attribute on the root, not the CSS direction property. dir carries semantic meaning the browser and assistive tech use for bidi; CSS direction does not. Then write layout with CSS logical properties so one stylesheet serves both directions.
| Physical (don't) | Logical (do) |
|---|---|
margin-left |
margin-inline-start |
padding-right |
padding-inline-end |
text-align: left |
text-align: start |
left: 0 |
inset-inline-start: 0 |
border-right |
border-inline-end |
These auto-flip with dir/writing-mode, so RTL needs no separate stylesheet. Isolate interpolated user content (names, IDs) with <bdi> or unicode-bidi: isolate so a Hebrew username doesn't scramble surrounding LTR text.
See references/rtl-and-bidi.md for the full mapping, which icons to mirror vs. not, and RTL testing.
Phase 4 — translation workflow
The 2025–2026 pipeline is MT/TM pre-fill → LLM post-edit (MTPE) → human review, governed by a glossary and translation memory.
- Freeze the source. Translating moving copy doubles the work. Lock strings, then hand off.
- Build a glossary/termbase. Locks product terms ("Workspace", "Boost") to one approved translation per language — keeps terminology consistent and measurably cuts post-edit effort.
- Stand up translation memory (TM). Reuses approved translations for repeat/near-repeat segments across the product, so you never re-translate or re-pay for the same string.
- MT pre-fill, then LLM post-edit, then human review. MT/LLM gives a strong first pass; humans review for nuance. Upstream quality (clear source + glossary + style + TM) is what makes the post-edit cheap.
- Preserve placeholders/variables.
{count},%s,<b>…</b>must survive untouched — validate after every MT/LLM pass.
Never auto-translate unreviewed: legal/contract copy (route to ../brand-voice/SKILL.md and legal review for terms), and brand-voice-critical strings — those need the voice carried deliberately, not machine-flattened.
Testing
- Pseudolocalization first. Transform source strings: accent the letters, pad +30–40% length, wrap in brackets — e.g.
[Ŝéttîñgś……]. Surfaces hardcoded strings, truncation/overflow from text expansion, and broken concatenation before you hire a single translator. - Missing-key detection. Fail CI when a locale lacks a key the source has.
- Longest-language overflow check. German/Finnish expand most; verify buttons and fixed-width UI survive.
- Per-locale snapshot/visual diff, including one RTL locale.
- Fallback locale resolves, never the raw key.
Anti-patterns
| Anti-pattern | Why it breaks | Do instead |
|---|---|---|
| Concatenating translated fragments | Word order differs per language | One ICU message with named args |
count === 1 ? x : y |
Wrong for ~most languages (Russian, Arabic…) | ICU plural with CLDR categories |
| Date/number formats baked into strings | 1,000.50 is wrong in de-DE |
Intl.NumberFormat/DateTimeFormat |
margin-left everywhere |
Doesn't flip for RTL | CSS logical properties |
| Translating in raw spreadsheets, no TM | Drift, repeat work, lost terms | TM + glossary + a TMS |
| Machine-translating legal/brand copy unreviewed | Liability + flattened voice | Human/legal + brand-voice |
| No fallback locale | Missing key renders blank/raw | Configure source locale fallback |
| Using English text as the message key | Copy edit breaks all translations | Stable namespaced key |
Missing other clause in ICU |
Invalid message, formatter throws | Always include other |
| ICU formatting with no try/catch | One bad translation crashes the view | Catch → return key, log |
Setting direction via CSS, not HTML dir |
Loses bidi semantics | dir attribute on <html> |
| Assuming all languages expand equally | DE/FI overflow, ZH contracts | Pseudoloc + longest-language test |
Handoff checklist
i18n-ready: no hardcoded user-facing strings · catalog with stable namespaced keys · fallback locale set · all plurals via ICU · all dates/numbers via Intl · CSS logical properties · lang+dir on <html> · pseudoloc passes.
l10n-ready: glossary/termbase signed off · TM populated · MT→LLM→human pipeline defined · legal/brand strings flagged for human review · per-locale CI checks (missing keys, overflow, RTL snapshot) green.