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.

  • namei18n-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

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.

~/.claude/skills/i18n-localization/SKILL.md

--- name: i18n-localization description: Designs and implements internationalization for web apps — externalized ICU MessageFormat strings with plurals and select, Intl APIs for… license: MIT ---

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

StackDefaultNotes
Next.js App Routernext-intlServer Component support, locale routing middleware/proxy, ICU
React (other)react-intl (FormatJS)ICU native, formatjs extract CLI
Any JS, large existing ecosystemi18next + react-i18nextUses its own plural suffixes (key_one, key_other) unless i18next-icu is added; pick one style per project
Vue / Nuxtvue-i18n / @nuxtjs/i18nSupports pluralization and locale routing
Bundle-size sensitive, typed messagesParaglide (inlang)Compiles messages to tree-shakable functions
SvelteKitParaglide 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/select has an other branch. 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 timeZone on 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 .length or .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; see css-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> or dir="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

StrategyUse 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 URLLogged-in app UI where SEO does not matter
  • Detect from Accept-Language only 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 plus hreflang="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-phrase where supported or the default line breaking, never break-all for body text.
  • Do not use flags for languages; use language names in their own language (Deutsch, 日本語).

Translation workflow

  1. Source locale files are the single source of truth in the repo.
  2. Extract messages automatically (formatjs extract, i18next-parser, framework tooling) in CI so unused and new keys are visible.
  3. Sync with a TMS (Crowdin, Lokalise, Phrase) via its CLI or GitHub integration; translators work there, PRs bring translations back.
  4. Freeze source strings before a release cut; late changes ship with fallback.
  5. 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

  1. Checker passes for all locales.
  2. Pseudo-locale walkthrough of key screens: no untranslated strings, no clipped text.
  3. RTL locale walkthrough: layout mirrored, icons correct, mixed text readable.
  4. Formatting spot-check in two locales: currency, dates in a non-UTC time zone, plural edge cases (0, 1, 2, 5, 21, 1.5).
  5. SSR: page rendered by the server and hydrated in a browser with a different default locale and time zone shows no hydration warnings.
  6. View source: correct lang, dir, and reciprocal hreflang links.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/check_locales.py 200 lines · 7.6 KB
#!/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.

esc