Skip to content

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.mdlocales:validate, locales:quality, locales:missing, locales:doctor
  • sync.mdlocales:sync (key-shape sync from en.json)
  • ci.md — CI gate commands (no locales:ci script)
  • 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

  1. main.tsx imports ./i18n so the instance initializes before the React tree.
  2. Lazy locale loading: src/i18n/index.ts ships en in baseResources and loads non-en JSON files on demand via localeLoaders. Loaded bundles are passed through stripLocaleBundleToStrings() so i18next only receives plain string leaves (structured metadata is stripped — see locale-leaves.md).
  3. setAppLanguage() calls ensureLocaleLoaded() before i18n.changeLanguage() so language switching works even when locale bundles were not loaded yet.
  4. LanguageSync (src/i18n/sync.tsx) reads user.settings.language from /me and calls setAppLanguage().
  5. First-time language suggestion (src/layouts/main/language) compares current language vs browser/IP (/ipinfo) and shows a footer-style prompt once.
  6. Settings + language dialog save via meService.updateSettings first, then apply via setAppLanguage() so profile settings remain source-of-truth.
  7. Unknown or legacy API values (e.g. old codes not in SUPPORTED_LANGUAGES) map to en via normalizeLanguageTag().

Key structure (enterprise layout)

Locale JSON is grouped for discovery and ownership:

PrefixPurpose
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

  1. Add English strings under apps/web/locales/en.json following the hierarchy above.
  2. Align non-English files with pnpm locales:sync after en.json changes (see locale-leaves.md for structured leaves).
  3. Add or refresh translations with i18nprune generate / i18nprune fill (see docs.i18nprune.dev).
  4. QApnpm locales:validate + pnpm locales:quality; optional path-level identical report via pnpm exec i18nprune kits identical.
  5. Register new languages — the generator updates src/i18n/config.ts and src/i18n/index.ts when possible; otherwise follow generate.md manually.
  6. Use prefixes from src/i18n/keys.ts in pages: t(`&#36;{i18nKey.pages.domain.shared.settings}.title`).
  7. Do not render API error message text directly for known failures — map codeerrors.codes.<CODE>.

Maintenance checklist

  • [x] Keep en.json complete; other locales may lag but keys should exist in en.
  • [x] When renaming a key, grep the codebase and update all locale files.
  • [x] Optional CI: pnpm -C apps/web locales:validate and locales:quality (see ci.md).
  • [x] i18next declaration merge is enabled via src/types/i18n/locales.d.ts (keys inferred from locales/en.json).
  • [x] Navigation/recents now use typed keys (TranslationKey in src/types/i18n/index.ts) instead of generic string.
  • [ ] Continue migrating remaining dynamic t(string) callsites to typed keys/wrappers.

Scale plan (when key count grows)

  • Keep current flat translation namespace 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.
  • apps/web/locales/en.json, ar.json, zh-cn.json, …
  • apps/web/i18nprune.config.ts
  • apps/web/src/i18n/config.ts, index.ts, loaders.generated.ts, keys.ts, sync.tsx
  • apps/web/src/types/i18n/locales.d.ts, apps/web/src/types/i18n/index.ts