i18n and Localization skill for Claude Code
ICU messages, Intl formatting, RTL that holds up. Claude internationalizes an app the way localization teams need: complete ICU sentences with named arguments instead of concatenated fragments, Intl formatters with explicit locale and time zone, logical CSS and bidi isolation for Arabic and Hebrew, and path-based locale routing with reciprocal hreflang. A bundled checker fails CI on missing keys, broken ICU syntax and placeholder mismatches.
- name
i18n-localization - size173 lines · ≈2,200 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and implements internationalization for web apps — externalized ICU MessageFormat strings with plurals and select, Intl APIs for numbers, currencies, dates, relative time and lists, RTL layout with logical CSS and bidi isolation, locale routing with hreflang, and a translation workflow with extraction, pseudo-localization, and a bundled locale-file checker. Use when adding a second language, choosing an i18n library (next-intl, react-intl, i18next, vue-i18n, Paraglide), fixing broken plurals or concatenated strings, supporting Arabic or Hebrew, or setting up translation CI. Try asking
- Add German and Arabic to this Next.js app — set up routing and messages
- Our plural strings are wrong in Polish; fix how we handle counts
- Add a CI check that catches missing translations and broken placeholders
Use it when
- Adding a second language or market to a web app
- Choosing between next-intl, react-intl, i18next, vue-i18n or Paraglide
- Strings are concatenated or plurals are handled with if statements
- Supporting right-to-left languages
- Setting up translation management and CI checks
Skip it when
- You only need hreflang and international SEO audits — use technical-seo
- The app is native iOS or Android with platform string catalogs — use swiftui-views or jetpack-compose
How to check the result
The locale checker passes, a pseudo-locale walkthrough shows no hardcoded or clipped strings, and an RTL locale renders mirrored layouts with readable mixed-direction text.
The common mistake it prevents
Building sentences by concatenating translated fragments, which locks in English word order and makes correct plurals and gender impossible in other languages.
An app is properly internationalized when no user-facing string is hardcoded or built by
concatenation, every message is a complete ICU sentence with named arguments, numbers
and dates are formatted by Intl for the active locale and time zone, layouts survive
30–40% text expansion and right-to-left scripts, and CI fails on missing keys or
placeholder mismatches.
Before changing anything
grep -rln "i18next\|react-intl\|next-intl\|vue-i18n\|@inlang/paraglide\|@lingui" package.json src 2>/dev/null
ls locales messages src/locales public/locales 2>/dev/null
grep -rn "toLocaleString()\|toLocaleDateString()\|new Intl" src | head # implicit-locale formatting
Check how locale is chosen today (URL, cookie, Accept-Language, user profile) and where
the source-language strings live.
Workflow
- [ ] 1. Choose locale strategy: URL structure, detection, persistence, fallback chain
- [ ] 2. Choose library and message format (ICU)
- [ ] 3. Externalize strings with semantic keys and translator descriptions
- [ ] 4. Replace concatenation and manual plurals with ICU messages
- [ ] 5. Replace ad hoc formatting with Intl (numbers, currency, dates, lists)
- [ ] 6. Make layout direction-agnostic (logical CSS, dir, bidi isolation)
- [ ] 7. Pseudo-localize and fix truncation/hardcoded strings
- [ ] 8. Wire extraction + TMS sync + CI checks
Library choice
| Stack | Default | Notes |
|---|---|---|
| Next.js App Router | next-intl | Server Component support, locale routing middleware/proxy, ICU |
| React (other) | react-intl (FormatJS) | ICU native, formatjs extract CLI |
| Any JS, large existing ecosystem | i18next + react-i18next | Uses its own plural suffixes (key_one, key_other) unless i18next-icu is added; pick one style per project |
| Vue / Nuxt | vue-i18n / @nuxtjs/i18n | Supports pluralization and locale routing |
| Bundle-size sensitive, typed messages | Paraglide (inlang) | Compiles messages to tree-shakable functions |
| SvelteKit | Paraglide or svelte-i18n |
Message rules
{
"cart.summary": "{count, plural, =0 {Your cart is empty} one {# item in your cart} other {# items in your cart}}",
"invite.sent": "{inviter} invited {guestCount, plural, one {one guest} other {# guests}} to {eventName}",
"profile.updated": "{gender, select, female {She updated her profile} male {He updated his profile} other {They updated their profile}}",
"rank.place": "You finished {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}",
"order.total": "Total: {amount, number, ::currency/EUR}"
}
- One message per complete sentence. Never concatenate:
t("hello") + name + t("welcome")breaks word order in most languages. - Named arguments only (
{name}), never positional. - Every
plural/selecthas anotherbranch. Languages need different categories: English uses one/other, Polish adds few/many, Arabic uses zero/one/two/few/many/other, Japanese only other. Translators add the categories their language needs. - Keys are semantic and stable (
checkout.payment.cardDeclined), not the English text. Changing English copy should not change keys unless meaning changes. - Give translators context: a description per message, max length for constrained UI, and screenshots via the TMS.
- Do not embed HTML in messages; use rich-text tags the library maps to components
(
"terms": "I agree to the <link>terms</link>"). - Do not translate fragments of a sentence separately to style one word — use rich-text tags.
Formatting with Intl
const locale = "de-DE";
new Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }).format(1234.5); // "1.234,50 €"
new Intl.NumberFormat(locale, { notation: "compact" }).format(1_200_000); // "1,2 Mio."
new Intl.NumberFormat(locale, { style: "unit", unit: "kilometer-per-hour" }).format(80);
new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: user.timeZone }).format(date);
new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-1, "day"); // "gestern"
new Intl.ListFormat(locale, { type: "conjunction" }).format(["Anna", "Ben", "Cem"]); // "Anna, Ben und Cem"
new Intl.DisplayNames([locale], { type: "region" }).of("JP"); // "Japan"
["Äpfel", "Zebra", "apfel"].sort(new Intl.Collator(locale).compare);
Rules:
- Always pass the locale explicitly;
toLocaleString()with no argument uses the server’s or browser’s default and causes SSR hydration mismatches. - Store timestamps in UTC (ISO 8601), format in the user’s time zone. Pass
timeZoneon the server; otherwise it formats in the server’s zone. - Currency comes from the data, not the locale: store
{ amount: 129900, currency: "JPY" }in minor units and format with the currency’s own digits. - Reuse formatter instances (create once per locale) in hot paths; construction is slow.
- Truncate or count characters by grapheme with
Intl.Segmenter, not.lengthor.slice(), to avoid cutting emoji and combining marks. - Case conversion is locale-sensitive:
"title".toLocaleUpperCase("tr-TR").
Right-to-left support
- Set
<html lang="ar" dir="rtl">from the active locale on the server. - Use logical CSS properties (
margin-inline-start,padding-inline,inset-inline-end,text-align: start) everywhere; seecss-architecture. - Mirror icons that imply direction (back/forward arrows, progress chevrons) with
:dir(rtl) .icon-arrow { transform: scaleX(-1); }. Do not mirror logos, media controls’ play icon, clocks, or checkmarks. - Wrap user-generated or mixed-direction text in
<bdi>ordir="auto"so a Latin username inside Arabic text does not scramble punctuation. - Numbers, phone numbers, and code stay LTR inside RTL text; isolate them.
- Test with a real RTL locale, not just
dir="rtl"on English.
Locale routing and SEO
| Strategy | Use when |
|---|---|
Path prefix (/de/pricing) | Default for most sites; one domain, clear URLs, easy hreflang |
Subdomain (de.example.com) | Separate infrastructure or teams per market |
ccTLD (example.de) | Strong country targeting, legal entity per country |
| Cookie/header only, same URL | Logged-in app UI where SEO does not matter |
- Detect from
Accept-Languageonly to suggest or redirect on the first visit; persist an explicit user choice in a cookie or profile and always let users switch. - Never redirect based on IP alone, and never block crawlers from other-locale URLs.
- Emit
<link rel="alternate" hreflang="de" href="...">for every translation plushreflang="x-default", reciprocal across all versions. - Fallback chain:
pt-BR→pt→ default locale. Log missing keys in development rather than showing raw keys.
Pseudo-localization and layout
- Run a pseudo-locale that wraps and expands strings (
[Ŝéţţîñĝš ~~~~]) to find hardcoded strings (they stay plain English) and truncation (brackets get cut). - Budget for expansion: German and Finnish run 30–40% longer than English; short labels can double. Avoid fixed widths on buttons and tabs; allow wrapping.
- CJK text has no spaces; use
word-break: auto-phrasewhere supported or the default line breaking, neverbreak-allfor body text. - Do not use flags for languages; use language names in their own language
(
Deutsch,日本語).
Translation workflow
- Source locale files are the single source of truth in the repo.
- Extract messages automatically (
formatjs extract,i18next-parser, framework tooling) in CI so unused and new keys are visible. - Sync with a TMS (Crowdin, Lokalise, Phrase) via its CLI or GitHub integration; translators work there, PRs bring translations back.
- Freeze source strings before a release cut; late changes ship with fallback.
- Run the checker in CI:
python3 scripts/check_locales.py src/locales --base en
python3 scripts/check_locales.py public/locales --base en --show-untranslated
It handles locales/<locale>.json or locales/<locale>/<namespace>.json layouts,
reports missing/extra/empty keys, ICU syntax errors, plural/select without other, and
placeholder mismatches (ICU {name} and i18next {{name}}), and exits 1 on errors.
Verification
- Checker passes for all locales.
- Pseudo-locale walkthrough of key screens: no untranslated strings, no clipped text.
- RTL locale walkthrough: layout mirrored, icons correct, mixed text readable.
- Formatting spot-check in two locales: currency, dates in a non-UTC time zone, plural edge cases (0, 1, 2, 5, 21, 1.5).
- SSR: page rendered by the server and hydrated in a browser with a different default locale and time zone shows no hydration warnings.
- View source: correct
lang,dir, and reciprocalhreflanglinks.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/check_locales.py
#!/usr/bin/env python3
"""Check translation files against a base locale (stdlib only).
Supported layouts (JSON, nested objects allowed):
locales/en.json, locales/de.json -> one file per locale
locales/en/common.json, locales/de/common.json -> one folder per locale, files are namespaces
Checks per non-base locale:
- missing keys (present in base, absent in locale) -> error
- extra keys (absent in base) -> warning
- empty values -> error
- placeholder mismatch: ICU {name} / {n, plural, ...} and i18next {{name}} arguments -> error
- ICU plural/select without an "other" branch -> error
- values identical to base (possible untranslated) -> info, with --show-untranslated
Usage:
python3 check_locales.py LOCALES_DIR [--base en] [--show-untranslated] [--warn-extra-as-error]
Exit 1 if any error, 2 on bad input.
"""
import argparse
import json
import os
import re
import sys
def flatten(obj, prefix=""):
out = {}
if isinstance(obj, dict):
for k, v in obj.items():
out.update(flatten(v, f"{prefix}.{k}" if prefix else str(k)))
elif isinstance(obj, list):
for i, v in enumerate(obj):
out.update(flatten(v, f"{prefix}[{i}]"))
else:
out[prefix] = obj
return out
def load_layout(root):
entries = sorted(os.listdir(root))
files = [e for e in entries if e.endswith(".json") and os.path.isfile(os.path.join(root, e))]
dirs = [e for e in entries if os.path.isdir(os.path.join(root, e)) and not e.startswith(".")]
locales = {}
if files:
for f in files:
with open(os.path.join(root, f), encoding="utf-8") as fh:
locales[f[:-5]] = flatten(json.load(fh))
elif dirs:
for d in dirs:
merged = {}
for f in sorted(os.listdir(os.path.join(root, d))):
if f.endswith(".json"):
with open(os.path.join(root, d, f), encoding="utf-8") as fh:
merged.update(flatten(json.load(fh), f[:-5]))
locales[d] = merged
return locales
class ICUError(Exception):
pass
def icu_args(msg: str):
"""Return (set of argument names, list of problems) for an ICU message string."""
names, problems = set(), []
for m in re.finditer(r"\{\{\s*([\w.-]+)", msg): # i18next interpolation
names.add(m.group(1))
msg = re.sub(r"\{\{.*?\}\}", "", msg)
def skip_quoted(s, i):
# ICU: '' is a literal apostrophe; '{...}' quotes syntax characters
if s.startswith("''", i):
return i + 2
if i + 1 < len(s) and s[i + 1] in "{}#|":
j = s.find("'", i + 1)
return len(s) if j == -1 else j + 1
return i + 1
def parse_message(s, i, stop_at_brace):
while i < len(s):
c = s[i]
if c == "'":
i = skip_quoted(s, i); continue
if c == "{":
i = parse_arg(s, i + 1); continue
if c == "}":
if stop_at_brace:
return i
raise ICUError("unbalanced '}'")
i += 1
if stop_at_brace:
raise ICUError("missing '}'")
return i
def parse_arg(s, i):
m = re.compile(r"\s*([\w.-]+)\s*").match(s, i)
if not m:
raise ICUError(f"bad argument at {i}")
names.add(m.group(1)); i = m.end()
if i < len(s) and s[i] == "}":
return i + 1
if i >= len(s) or s[i] != ",":
raise ICUError(f"expected ',' or '}}' after {m.group(1)}")
m2 = re.compile(r",\s*(\w+)\s*").match(s, i)
if not m2:
raise ICUError("missing argument type")
typ = m2.group(1); i = m2.end()
if typ in ("plural", "selectordinal", "select"):
if i < len(s) and s[i] == ",":
i += 1
keys = []
while True:
m3 = re.compile(r"\s*(offset:\s*\d+\s*)?").match(s, i); i = m3.end()
if i < len(s) and s[i] == "}":
break
m4 = re.compile(r"\s*(=?[\w-]+)\s*\{").match(s, i)
if not m4:
raise ICUError(f"bad {typ} option near: {s[i:i + 20]!r}")
keys.append(m4.group(1)); i = m4.end()
i = parse_message(s, i, True) + 1
if "other" not in keys:
problems.append(f"{typ} without 'other' branch")
return i + 1
depth = 1 # number/date/time/etc: skip style
while i < len(s) and depth:
depth += {"{": 1, "}": -1}.get(s[i], 0); i += 1
if depth:
raise ICUError("missing '}'")
return i
try:
parse_message(msg, 0, False)
except ICUError as e:
problems.append(f"ICU syntax: {e}")
return names, problems
def main() -> int:
ap = argparse.ArgumentParser(description="Check locale files against a base locale")
ap.add_argument("root")
ap.add_argument("--base", default="en")
ap.add_argument("--show-untranslated", action="store_true")
ap.add_argument("--warn-extra-as-error", action="store_true")
args = ap.parse_args()
if not os.path.isdir(args.root):
print(f"error: {args.root} is not a directory", file=sys.stderr); return 2
try:
locales = load_layout(args.root)
except (OSError, json.JSONDecodeError) as e:
print(f"error: {e}", file=sys.stderr); return 2
if args.base not in locales:
print(f"error: base locale '{args.base}' not found; found {sorted(locales)}", file=sys.stderr); return 2
base = locales[args.base]
errors = warnings = 0
base_args = {}
for k, v in base.items():
if isinstance(v, str):
names, probs = icu_args(v)
base_args[k] = names
for p in probs:
print(f"ERROR [{args.base}] {k}: {p}"); errors += 1
for loc in sorted(locales):
if loc == args.base:
continue
msgs = locales[loc]
missing = sorted(set(base) - set(msgs))
extra = sorted(set(msgs) - set(base))
for k in missing:
print(f"ERROR [{loc}] missing key: {k}"); errors += 1
for k in extra:
level = "ERROR" if args.warn_extra_as_error else "WARN "
print(f"{level} [{loc}] extra key: {k}")
errors += args.warn_extra_as_error; warnings += not args.warn_extra_as_error
for k in sorted(set(base) & set(msgs)):
v = msgs[k]
if isinstance(v, str):
if not v.strip():
print(f"ERROR [{loc}] empty value: {k}"); errors += 1; continue
names, probs = icu_args(v)
for p in probs:
print(f"ERROR [{loc}] {k}: {p}"); errors += 1
want = base_args.get(k, set())
if names != want:
miss, add = sorted(want - names), sorted(names - want)
detail = "; ".join(x for x in (f"missing {miss}" if miss else "", f"unknown {add}" if add else "") if x)
print(f"ERROR [{loc}] placeholder mismatch in {k}: {detail}"); errors += 1
if args.show_untranslated and v == base.get(k) and len(v) > 3:
print(f"INFO [{loc}] identical to base: {k}")
print(f"--- {loc}: {len(msgs)} keys, {len(missing)} missing, {len(extra)} extra")
print(f"\nlocales: {len(locales)} (base {args.base}, {len(base)} keys) errors: {errors} warnings: {warnings}")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.