react-i18next with i18next, in the plain Vite and React app. It gives you plain t('key') calls with no extraction step, namespace-based lazy loading, and typed keys.
Where strings live
Translations are inweb/src/locales/<language>/<namespace>.json. A namespace maps to a feature area, not to one component: deploys.json holds keys for several deploy-settings cards. The current namespaces are common, deploys, settings, dashboard, auditLog, streams, networkProxy, databases, https and nodes.
Nest keys by component inside the file, and name a key for what the string is for, not its English text:
Add a string to a component
1
Add the key to the JSON file
Find or create the namespace file under
web/src/locales/en/ and add the key first. The JSON file is the type schema, so a call site cannot use a key that is not there.2
Call t() in the component
{{placeholder}} in the value and pass the data as the second argument: t('autoRollbackSloBurn.toast.modeSet', { mode: label }). For strings from more than one namespace, pass an array to useTranslation and prefix the non-default key, for example t('common:actions.copy').3
Cover every user-facing string
That includes
aria-label values, toast titles and descriptions, tooltip text and alt text. Test ids, internal log messages and class names do not belong in a namespace file.4
Check types
From
web/, run npx tsc -b and npx eslint src. A missing or mistyped key fails tsc.Adding a new namespace
Create the JSON file, then register it in two places: add the name toNAMESPACES in web/src/i18n/index.ts and add its type to web/src/i18n/resources.ts.
How loading works
Each namespace is its own chunk.web/src/i18n/lazyBackend.ts imports ../locales/${language}/${namespace}.json with a fixed directory and extension, which lets Vite split every JSON file out of the main bundle. A namespace downloads the first time a component calls useTranslation for it. Only common, the default namespace, loads at boot.
Because useSuspense is on, a component suspends while its namespace loads. web/src/main.tsx wraps the app in one <Suspense fallback={<PageSpinner />}>, so this looks like any other route loading state. If you render a component outside that tree, make sure an ancestor has a Suspense boundary.
Type-safe keys
web/src/i18n/resources.ts augments i18next’s CustomTypeOptions with the type of each en JSON file:
t('autoRollback.titl') is a tsc error at the call site instead of a silent fallback to the raw key at runtime. This needs "resolveJsonModule": true in tsconfig.app.json, which is set.
Testing
Test a translated component with a dedicatedi18next.createInstance() seeded with the real namespace JSON through I18nextProvider, not the app’s lazy-loading singleton. Tests then get deterministic, already-loaded translations with no chunk-loading race. web/src/components/AutoRollbackCard.test.tsx shows the pattern.
Not done yet
- No second language. Add one only when there is a real reason to ship it.
- No language switcher.
i18next-browser-languagedetectoris wired in andsupportedLngsis['en'], so adding a language and a switcher later won’t need another migration of call sites.