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.

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.json to other locale files.
  • locale-leaves.md — Structured leaf schema and source / updatedAt on non-English JSON.
  • review.mdpnpm locales:review (metadata-only analysis; multiple report formats).
  • validate.mdlocales:validate and locales:validate:quality.
  • ci.mdlocales:ci (aggregate checks for CI).
  • manage.md — Locale list/delete/cleanup management commands.
  • cleanup.md — What locales:cleanup does (including key drift detection).
  • delete.md — What locales:delete does.
  • 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

  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 (adds keys, merges missing review fields on structured leaves — see locale-leaves.md).
  3. Add or refresh translations with pnpm locales:generate (see generate.md) and/or pnpm locales:fill for English-identical strings only.
  4. QA metadata (optional): pnpm locales:review summarizes status, confidence, and needsReview per locale — see review.md. Parity vs English remains pnpm locales:validate:quality.
  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: run pnpm locales:validate.
  • [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, so.json
  • apps/web/src/i18n/config.ts, index.ts, keys.ts, sync.tsx
  • apps/web/src/types/i18n/locales.d.ts, apps/web/src/types/i18n/index.ts
  • apps/web/scripts/locales/generate/*, fill/*, sync/*, validate/*, shared/*