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.
Tool-specific docs:
- generate.md — Translate locales from
en.json(full/incremental) and register new languages. - fill.md — Re-translate English-identical strings only (
pnpm locales:fill). - sync.md — Key-shape sync from
en.jsonto other locale files. - locale-leaves.md — Structured leaf schema and
source/updatedAton non-English JSON. - review.md —
pnpm locales:review(metadata-only analysis; multiple report formats). - validate.md —
locales:validateandlocales:validate:quality. - ci.md —
locales:ci(aggregate checks for CI). - manage.md — Locale list/delete/cleanup management commands.
- cleanup.md — What
locales:cleanupdoes (including key drift detection). - delete.md — What
locales:deletedoes. - shared.md — Shared CLI utilities and preserve policies.
- files.md — Quick map of locale-related docs and code paths.
- examples.md — Real workflow examples for future contributors.
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 (adds keys, merges missing review fields on structured leaves — see locale-leaves.md). - Add or refresh translations with
pnpm locales:generate(see generate.md) and/orpnpm locales:fillfor English-identical strings only. - QA metadata (optional):
pnpm locales:reviewsummarizesstatus,confidence, andneedsReviewper locale — see review.md. Parity vs English remainspnpm locales:validate:quality. - 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: run
pnpm locales:validate. - [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,so.jsonapps/web/src/i18n/config.ts,index.ts,keys.ts,sync.tsxapps/web/src/types/i18n/locales.d.ts,apps/web/src/types/i18n/index.tsapps/web/scripts/locales/generate/*,fill/*,sync/*,validate/*,shared/*