Skip to main content
This page is for contributors to the dashboard. New user-facing strings go through i18n, but only English exists today, there is no language switcher, and many older components still hardcode English. Migrate a component when you touch it. No blanket sweep is planned. The stack is 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 in web/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:
A new feature area gets its own namespace file. Don’t grow an unrelated one.

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

For interpolation, use {{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 to NAMESPACES 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:
With that in place, 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 dedicated i18next.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-languagedetector is wired in and supportedLngs is ['en'], so adding a language and a switcher later won’t need another migration of call sites.
Last modified on October 12, 2026