i18next
Type-safe i18n with flat keys, on-demand namespace loading, Intl-based formatting, and i18next-cli for automated key management.
Architecture
- •Config:
apps/web/src/i18n/index.ts— i18next init, language config, custom formatters - •CLI Config:
apps/web/i18next.config.ts— i18next-cli extraction and validation settings - •Types:
apps/web/src/i18n/i18next.d.ts— module augmentation for type-safet() - •Locales:
apps/web/src/locales/{en-US,zh-CN}/{namespace}.json - •Loading:
i18next-resources-to-backenddynamic import, zero upfront cost (ns: [])
apps/web/
├── i18next.config.ts # CLI config (extraction, validation)
└── src/
├── i18n/
│ ├── index.ts # config + custom formatters
│ └── i18next.d.ts # type augmentation (update when adding namespace)
└── locales/
├── en-US/ # source of truth for types
│ ├── common.json # default namespace
│ ├── auth.json
│ ├── dashboard.json
│ ├── ai.json
│ └── dify.json
└── zh-CN/ # must have same keys as en-US (except plural variants)
i18next-cli
Automated key extraction, unused key detection, and multi-language sync. See references/cli.md for full configuration and command details.
Commands
Run from apps/web/:
pnpm i18n # extract keys from code, remove unused keys, sync languages pnpm i18n:ci # CI mode — fails if translation files need updating pnpm i18n:status # translation health report by namespace/language pnpm i18n:sync # sync secondary languages against primary (en-US) pnpm i18n:lint # detect hardcoded strings that should be translated
CLI-Compatible Code Patterns
CRITICAL — the CLI uses SWC static AST analysis. It can only extract t() calls with string literals. Violating these rules causes keys to be misidentified or deleted.
// ✅ CLI extracts correctly — string literal
t("nav.home")
// ❌ CLI CANNOT extract — variable
t(someVariable)
// ❌ CLI CANNOT trace namespace — t passed as function parameter
const helper = (t: TFunction) => t("key")
Rules to keep CLI happy:
- •Always use string literals in
t()calls — nevert(variable)ort(computedKey) - •Keep
t()calls insideuseTranslation()scope — the CLI tracesuseTranslation("ns")to determine which namespace a key belongs to. Iftis passed as a parameter to a utility function outside the component, the CLI loses namespace tracking and assigns keys tocommon(default) - •When a utility function needs translated strings, pass the strings not the
tfunction:
// ❌ WRONG — CLI cannot trace t's namespace
const getLabel = (t: TFunction) => t("my.key");
// ✅ RIGHT — t() called inside component scope, plain strings passed out
const { t } = useTranslation("ai");
const label = getLabel({ myKey: t("my.key") });
Key Naming Rules
CRITICAL — keys are flat strings, NOT nested paths (keySeparator: false).
{ "nav.home": "Home", "nav.dashboard": "Dashboard" }
- •Use
category.itemgrouping within a namespace:signIn.email,validation.passwordMin - •Use camelCase:
emptyState,apiMessage - •Plurals use
_one/_othersuffixes:eventsCount_one,eventsCount_other - •Never prefix keys with namespace name —
auth.jsonkeys must not start withauth. - •Never use nested JSON —
{ "nav": { "home": "..." } }is wrong
Adding a New Namespace
- •Create
locales/en-US/{ns}.jsonandlocales/zh-CN/{ns}.json - •Add import + resource entry in
i18next.d.ts:
import type settings from "../locales/en-US/settings.json"; // inside CustomTypeOptions.resources: settings: typeof settings;
- •Run
pnpm i18nto validate extraction
Adding Keys to Existing Namespace
- •Add key to
locales/en-US/{ns}.json(source of truth) - •Add key to
locales/zh-CN/{ns}.json - •JSON keys must be alphabetically sorted (enforced by Biome
useSortedKeys) - •JSON must use tab indentation (enforced by Biome)
- •Run
pnpm i18n:cito verify — or just writet("newKey")in code and runpnpm i18nto auto-generate
Usage Patterns
// React component — namespace via hook
const { t } = useTranslation("auth");
t("signIn.email");
// Default namespace (common)
const { t } = useTranslation();
t("nav.home");
// With interpolation
t("welcome", { name: "Alice" }); // "Welcome Alice"
// With count (auto plural resolution)
t("items", { count: 5 }); // selects _one or _other based on CLDR
Pluralization
English needs _one + _other. Chinese only needs _other (CLDR rules).
// en-US
{ "items_one": "{{count}} item", "items_other": "{{count}} items" }
// zh-CN
{ "items_other": "{{count}} 个项目" }
Call with base key — i18next resolves the suffix automatically:
t("items", { count: 5 });
Formatting (built-in Intl API)
{
"count": "{{val, number}}",
"price": "{{val, currency(USD)}}",
"date": "{{val, datetime}}",
"elapsed": "{{val, duration}}"
}
Custom duration formatter in index.ts — accepts milliseconds, outputs locale-aware seconds.
Rules
- •Always use string literal
t()calls — CLI cannot extract dynamic keys - •Keep
t()insideuseTranslation()scope — pass translated strings to helpers, not thetfunction - •Never prefix keys with namespace name — namespace is already the file
- •Keep JSON keys sorted alphabetically —
pnpm checkenforces this - •zh-CN must match en-US keys — except
_one(Chinese doesn't need it) - •Always pass raw values to formatters — let i18next handle conversion
- •Run
pnpm i18n:cibefore committing — or let CI catch it