plugins/frontend-toolkit/skills/i18n-localization/SKILL.md
Set up or audit internationalization — message extraction, locale routing strategy, server/client message split, ICU plurals, date/number/currency formatting, RTL, missing-key fallback, pseudo-localization testing. Use when adding a second locale, when hardcoded strings accumulate, or before going multi-region. Not for emitting hreflang/per-locale canonical tags (use seo-metadata) or deciding static vs dynamic rendering of locale routes (use render-strategy-decision).
npx skillsauth add jaykim88/claude-ai-engineering i18n-localizationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Make the app translatable without scattering locale logic through the codebase. All user-facing text comes from message catalogs; locale is resolved once at the routing boundary; formatting (dates, numbers, plurals) goes through the i18n layer, never hand-rolled.
Universal — message catalogs, locale negotiation, ICU message format (plurals/gender/select), and RTL handling are framework-agnostic concerns; only the library API and routing integration differ.
Choose the locale routing strategy
/en/about, /ko/about) — best for SEO, explicit, shareable. Default choice.example.de, de.example.com) — strong regional signal, heavier opsAccept-Language, redirect to the matched locale, persist the user's explicit choice in a cookie, and always have a default localeen-US vs en-GB, pt-BR vs pt-PT differ in formatting and spelling — key by the full locale where it mattersCentralize message catalogs
messages/en.json, messages/ko.json)t() accessor2b. Plan the translation workflow and missing-key behavior
app.title) to users; in dev, surface missing keys loudly so they're caught before releaseSplit server vs client messages
Use ICU message format for non-trivial strings
{count, plural, one {# item} other {# items}} — never string-concatenate count + nounone/other, but Arabic has six (zero/one/two/few/many/other) and Polish/Russian have complex rules — provide every category the target language needs, don't assume English's twoRoute all formatting through the i18n layer
Intl.NumberFormat via the library, never manual toFixed + symbolHandle RTL and text expansion
dir="rtl" at the html level for RTL locales; use CSS logical properties (margin-inline-start, not margin-left) — coordinate with responsive-design<bdi> or dir="auto" — or it renders garbledAudit for hardcoded strings (validation loop)
t()[!! Ḗḓīŧ ṗřǿƒīŀḗ !!]) to catch both hardcoded strings (they stay un-accented) and text-expansion/truncation breakage — before real translations exist| ❌ Anti-pattern | ✅ Correct |
|---|---|
| <p>You have {count} items</p> | ICU plural: t('items', { count }) → {count, plural, ...} |
| '$' + price.toFixed(2) | Intl.NumberFormat(locale, { style: 'currency', currency }) |
| margin-left in a layout that must support RTL | CSS logical property margin-inline-start |
| Cookie-only locale on a public marketing page | Path-prefix routing (/ko/...) for crawlability |
| Shipping all locales' catalogs to the client | Send only the active locale + needed namespaces |
| Rendering a missing key as app.title | Fall back to the default locale's string |
| Assuming one/other plurals for every language | Provide all CLDR categories the target needs |
| Tier | Examples | Action SLA |
|---|---|---|
| Critical | Raw message keys (app.title) shown to users; source-language text leaking into a translated page on a public route | Block release; fix immediately |
| Major | Hardcoded user-facing strings in components; plurals concatenated (count + noun) or missing CLDR categories the language needs; currency/date formatted by hand instead of Intl | Fix this sprint |
| Minor | Missing dir/logical properties for an RTL locale on the roadmap; layout truncating under text expansion; cookie-only locale on a non-critical page | Schedule within 2 sprints |
Intl through the i18n layerdir set per locale (if RTL on roadmap)messages/<locale>.json namespaced by featuredocs/adr/ADR-NNN-i18n-strategy.md (routing strategy + library choice)Intlfeat(i18n): add <locale> / refactor(i18n): extract <feature> strings to catalognext-intl (App Router-native — works in both Server and Client Components, middleware routing)next-intl/middleware with localePrefix ('always' | 'as-needed') + a [locale] route segmentmessages/<locale>.json; getTranslations() in Server Components, useTranslations() in Client ComponentsuseFormatter() / getFormatter() (wraps Intl.DateTimeFormat, Intl.NumberFormat)getMessageFallback / onError — fall back to the default locale in prod, throw/log in devmessages/*.json; pseudo-locale via a build step or a generated fake-locale cataloghreflang alternates + per-locale canonical (coordinate with seo-metadata)@nuxtjs/i18n (routing + lazy catalogs built in); vue-i18n core with $t + ICU via @intlify/*inlang/paraglide-js (compile-time, tree-shakable) or svelte-i18n; routing via [lang] param + hooks@angular/localize (compile-time) or @ngx-translate/core (runtime); locale via LOCALE_IDIntl.* APIs are built into every browser/runtime; RTL via CSS logical properties is framework-agnosticseo-metadata — hreflang + per-locale canonical tags pair with i18n routingrender-strategy-decision — locale-segmented routes affect static vs dynamic choicedesign-system-construction — components must tolerate text expansion + RTL[locale] segment), then read messages from a centralized catalog — never inline strings or hand-roll plural/currency logic. Ship only the active locale's needed namespaces to the client; sending all catalogs is a silent bundle-bloat source. The i18n-specific QA technique is pseudo-localization (accented + expanded fake locale) — it surfaces hardcoded strings and truncation before real translations exist. Decide the translation workflow and missing-key fallback (default locale, never the raw key) up front, and remember plural categories are per-language (CLDR), not just English's one/other.next-intl is the recommendation for App Router specifically; react-i18next remains valid for non-Next React. Library choice should be an ADR, not assumed.development
Audit and optimize third-party scripts — analytics, tag managers, chat widgets, embeds — with the right loading strategy, performance budget, facades, and CSP/consent controls. Use when adding a script, when TBT/INP regress, when a GDPR/CCPA consent requirement arises, or before shipping. Not for first-party bundle size (use bundle-optimization) or broad Core Web Vitals diagnosis (use rendering-performance).
development
Apply the Testing Trophy (mostly integration tests with RTL + MSW, sparing E2E with Playwright) and set coverage thresholds. Use before new feature work, after bug fixes, when CI coverage falls below target, or when tests are flaky or break on every refactor. Not for wiring coverage gates + Playwright into the GitHub Actions matrix (use cicd-pipeline) or auditing WCAG a11y compliance (use accessibility-audit).
development
Inventory and prioritize technical debt — TODO/FIXME/HACK, any usage, deprecated APIs, untested logic — with impact × effort matrix. Use at quarter start, before a refactoring sprint, when a new teammate joins, or when feature velocity slows. Not for actually paying down debt (use code-refactoring) or recording a migration approach (use decision-records) — this only inventories and prioritizes.
development
Decision framework for choosing the right state location — URL, server cache, local component, or shared/global store. Use when state-sync bugs appear, prop drilling gets deep (3+ levels), filters/tabs lose state on reload, or quarterly review. Not for form state specifically (use form-ux) or when the state is actually server data (use api-caching-optimization).