Skip to content

Internationalization (i18n) ​

The dashboard is being prepared for multiple languages. This is infrastructure plus one proven example, not a launch: only en exists today, there is no language switcher yet, and most components still have hardcoded English strings. That is expected. This page is the pattern new code and future migration passes follow, not a status report on how much of the app has moved over.

Why react-i18next ​

react-i18next + i18next, not Lingui or next-intl, for a plain Vite + React SPA (not Next.js):

  • Smaller learning curve than Lingui's extraction-based workflow: plain t('key') calls, no separate extraction/compile step in the build.
  • Real namespace-based lazy loading, which matters here: a feature's strings should not bloat the bundle every other page pays for.
  • Mature TypeScript support via module augmentation (CustomTypeOptions), so a translation key is checked like any other identifier.
  • The most widely adopted choice for this exact stack, which means more examples, more Stack Overflow answers, and a lower bus-factor risk than a smaller library.

File structure: one namespace per feature area ​

Translation content lives under web/src/locales/<lng>/<namespace>.json. Only en exists so far:

web/src/locales/en/common.json    # shared strings: button labels, generic toasts
web/src/locales/en/deploys.json   # deploy-related cards: auto-rollback, badges, ...

A namespace maps to a feature area, not to one component. deploys.json already holds keys for three different cards (autoRollback, autoRollbackSloBurn, badgeSettings) because they are all deploy-settings UI, not three namespaces. Nest keys by component/feature inside the namespace file:

json
{
  "autoRollback": {
    "title": "Auto-rollback on crashloop",
    "enabledLabel": "Enabled",
    "toast": {
      "enabled": "Auto-rollback on crashloop enabled.",
      "errorTitle": "Could not update auto-rollback."
    }
  }
}

When a genuinely new feature area shows up (billing, teams, whatever comes next), give it its own namespace file rather than growing an unrelated one. When in doubt, a namespace should be small enough that loading it on one route does not pull in strings for ten others.

Loading: lazy, per namespace, not all up front ​

web/src/i18n/index.ts wires a small custom backend (web/src/i18n/lazyBackend.ts) instead of bundling every namespace into the main chunk:

ts
function importNamespace(language: string, namespace: string) {
  return import(`../locales/${language}/${namespace}.json`)
}

Vite statically analyzes that template literal (the directory and extension are fixed, only language/namespace vary) and gives every locales/*/*.json file its own chunk. A namespace's JSON only downloads the first time some component calls useTranslation('thatNamespace'), not on initial page load. This is the same shape as route-level code splitting (see web/vite.config.ts's TanStack Router plugin): the dashboard should not ship the log viewer's bundle, and it should not ship every feature's strings either.

react: { useSuspense: true } means a component suspends while its namespace's chunk is loading. web/src/main.tsx wraps the whole app in one <Suspense fallback={<PageSpinner />}> for this, the same PageSpinner already used as route pendingComponent fallback elsewhere, so a namespace load looks like any other route-level loading state.

common is the defaultNS and is listed in ns: ['common'] at init time, so it loads once on boot alongside everything else already needed to render a first screen. Every other namespace is lazy by default: just start calling useTranslation('yourNamespace') and it loads itself.

Adding a new translatable string ​

  1. Find (or create) the right namespace file under web/src/locales/en/.
  2. Add the key, nested under the component or feature it belongs to. Keep the key name describing what the string is for, not the English text itself (enabledLabel, not enabled_text).
  3. In the component:
tsx
import { useTranslation } from 'react-i18next'

export function SomeCard() {
  const { t } = useTranslation('deploys')
  return <p>{t('someFeature.someLabel')}</p>
}
  1. For interpolation, use in the JSON value and pass it as the second argument:
json
{ "toast": { "modeSet": "Auto-rollback on SLO burn set to: {{mode}}." } }
tsx
t('autoRollbackSloBurn.toast.modeSet', { mode: label })
  1. Every user-facing string counts, including ones that are easy to forget: aria-labels, toast titles/descriptions, tooltip text, alt text. A screen reader user or someone reading a toast is still a user. If it is not user-facing (a data-testid, an internal log message, a CSS class name), it does not belong in a namespace file.
  2. If the component needs strings from more than one namespace, pass an array: useTranslation(['deploys', 'common']), then t('common:actions.copy') with the namespace prefix for the non-default one.

TypeScript key safety ​

web/src/i18n/resources.ts augments i18next's own types using the real en JSON files as the source of truth:

ts
import type common from '../locales/en/common.json'
import type deploys from '../locales/en/deploys.json'

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common'
    resources: {
      common: typeof common
      deploys: typeof deploys
    }
  }
}

With this in place, t('autoRollback.titl') (a typo) is a tsc type error at the call site, not a silent fallback to the raw key string at runtime. This was verified directly: deliberately introducing a bad key into a migrated component and running npx tsc -b produced a real TS2345 error pointing at the call site; removing the typo made it compile again.

This also means adding a key only in the component's call site without adding it to the JSON file first does not work. The JSON file is the schema. Add the key there first.

tsconfig.app.json needs "resolveJsonModule": true for this to work (JSON imports, including the import type ones above, need it); that is already set.

What is migrated so far ​

AutoRollbackCard, AutoRollbackSLOBurnCard, and BadgeSettingsCard (all under web/src/components/) are the proof of pattern: every user-facing string in these three files, including toast copy and aria-labels, comes from deploys.json or common.json. Nothing else has been touched. The rest of the dashboard still has hardcoded English strings everywhere, on purpose: this was infrastructure plus one real example, not a full sweep.

Extending this to the next component ​

  1. Read the component's current content fresh (not from memory of an older version), since copy changes independently of i18n work.
  2. Pick (or create) the right namespace file.
  3. Move every user-facing string into it, nested under a key for that component/feature.
  4. Replace the hardcoded strings with t() calls, importing useTranslation from react-i18next.
  5. Run npx tsc -b and npx eslint src in web/. A missing or mistyped key fails tsc, not just a visual check.
  6. If the component renders real content behind a suspense boundary already (e.g. useSuspenseQuery), nothing extra is needed: the existing <Suspense> wrapping in main.tsx and in tests covers the namespace load too. If it does not, make sure some ancestor still has a <Suspense> boundary (it almost always already does).
  7. Write or extend a component test using a dedicated i18next.createInstance() seeded with the real namespace JSON via I18nextProvider, not the app's lazy-loading singleton: tests want deterministic, already-loaded translations, not a network/chunk-loading race. See web/src/components/AutoRollbackCard.test.tsx for the full pattern.

Not in scope here (yet) ​

  • No second language. No real French/Spanish/etc. translations exist, and none should until there is a real reason to ship one.
  • No language switcher UI. i18next-browser-languagedetector is wired into web/src/i18n/index.ts so adding one later is additive, not another migration, but building the switcher itself is a separate task.
  • No blanket migration. Hundreds of components still hardcode English strings. That is fine for now; migrate opportunistically (new code, and when a component is touched for other reasons) rather than as a dedicated sweep.

Released under the Apache 2.0 License.