Frontend internationalization (i18n)
This document describes how and why we localize the web app, and how to extend copy safely. For the architectural decision record, see ADR-013.
Tooling uses i18nprune (apps/web/i18nprune.config.ts). In-repo scripts/locales/** was removed.
Primary docs:
- validate.md —
locales:validate,locales:quality,locales:missing,locales:doctor - sync.md —
locales:sync(key-shape sync fromen.json) - ci.md — CI gate commands (no
locales:ciscript) - shared.md — preserve/parity policies in config
- locale-leaves.md — structured non-English leaf schema
- files.md — code + doc map
- examples.md — workflow examples (some legacy script names may remain)
Legacy per-command pages (generate.md, fill.md, review.md, …) may reference removed scripts; use i18nprune CLI for writes. Read-only deep audits: i18nprune kits (see maintainer/systems/i18n.md).
Why i18next
- Industry standard for React, pluralization, interpolation, and future namespaces.
- Stable keys decouple UI from English copy edits and from backend wording (errors use codes, not raw strings).
- English (
en) is the source of truth and fallback; missing keys in a locale fall back via i18next + our normalization rules.
Runtime behavior
main.tsximports./i18nso the instance initializes before the React tree.- Lazy locale loading:
src/i18n/index.tsshipseninbaseResourcesand loads non-enJSON files on demand vialocaleLoaders. Loaded bundles are passed throughstripLocaleBundleToStrings()so i18next only receives plain string leaves (structured metadata is stripped — see locale-leaves.md). setAppLanguage()callsensureLocaleLoaded()beforei18n.changeLanguage()so language switching works even when locale bundles were not loaded yet.LanguageSync(src/i18n/sync.tsx) readsuser.settings.languagefrom/meand callssetAppLanguage().- First-time language suggestion (
src/layouts/main/language) compares current language vs browser/IP (/ipinfo) and shows a footer-style prompt once. - Settings + language dialog save via
meService.updateSettingsfirst, then apply viasetAppLanguage()so profile settings remain source-of-truth. - Unknown or legacy API values (e.g. old codes not in
SUPPORTED_LANGUAGES) map toenvianormalizeLanguageTag().
Key structure (enterprise layout)
Locale JSON is grouped for discovery and ownership:
| Prefix | Purpose |
|---|---|
common.* | Buttons, generic labels reused across features |
branding.* | Product name and marketing-safe strings |
layouts.* | Auth layout, main shell, footer chrome |
navigation.* | Sidebar section titles + nav item labelKeys + recents |
router.* | Global route-level messages (load errors, guards) |
pages.domain.<area>.<page>.* | Feature screens matching src/domains/<area>/pages/... |
errors.codes.* | Map backend STRINGS / codes → user-facing text (no raw API messages) |
Navigation data (getRoleNavigation, SYSTEM_NAV_DEF, ACCOUNT_DEF, recents rules) stores labelKey strings (e.g. navigation.items.dashboard), not English sentences. UI calls t(labelKey).
Developer workflow
- Add English strings under
apps/web/locales/en.jsonfollowing the hierarchy above. - Align non-English files with
pnpm locales:syncafteren.jsonchanges (see locale-leaves.md for structured leaves). - Add or refresh translations with
i18nprune generate/i18nprune fill(see docs.i18nprune.dev). - QA —
pnpm locales:validate+pnpm locales:quality; optional path-level identical report viapnpm exec i18nprune kits identical. - Register new languages — the generator updates
src/i18n/config.tsandsrc/i18n/index.tswhen possible; otherwise follow generate.md manually. - Use prefixes from
src/i18n/keys.tsin pages:t(`${i18nKey.pages.domain.shared.settings}.title`). - Do not render API error
messagetext directly for known failures — mapcode→errors.codes.<CODE>.
Maintenance checklist
- [x] Keep
en.jsoncomplete; other locales may lag but keys should exist inen. - [x] When renaming a key, grep the codebase and update all locale files.
- [x] Optional CI:
pnpm -C apps/web locales:validateandlocales:quality(see ci.md). - [x]
i18nextdeclaration merge is enabled viasrc/types/i18n/locales.d.ts(keys inferred fromlocales/en.json). - [x] Navigation/recents now use typed keys (
TranslationKeyinsrc/types/i18n/index.ts) instead of genericstring. - [ ] Continue migrating remaining dynamic
t(string)callsites to typed keys/wrappers.
Scale plan (when key count grows)
- Keep current flat
translationnamespace while keys are manageable. - At ~2k+ keys, split into namespaces by ownership (for example:
common,dashboard,maintenance,system). - Load heavy namespaces lazily on route/domain entry to reduce initial bundle weight.
Related files
apps/web/locales/en.json,ar.json,zh-cn.json, …apps/web/i18nprune.config.tsapps/web/src/i18n/config.ts,index.ts,loaders.generated.ts,keys.ts,sync.tsxapps/web/src/types/i18n/locales.d.ts,apps/web/src/types/i18n/index.ts