Frontend
Adding a language
Add a new language to the SPA, where translation namespaces live, and how strings are looked up.
On this page
Where i18n lives
Normal translations live under frontend/src/shared/i18n/. i18n.ts loads English and the detected
locale through dynamic imports before the first render. Each locale is a separate asset; switching language
through changeLocale loads that asset before applying the latest selection. Menus must not call
i18n.changeLanguage directly, because that bypasses loading and selection ordering.
The selected locale is stored under slicekit.locale in local storage. Without a saved selection, detection uses
the browser language and falls back to English. The document language follows the active UI language.
If a locale asset cannot load during startup, the app shows a retry screen from independently bundled English and Dutch recovery translations. Automatic repair is bounded for the current build in that tab. Manual retry reloads the same URL and keeps the saved language; blocked session storage disables automatic reloads while leaving the retry button available.
How strings are looked up
There are no hard-coded UI strings. Every user-visible string goes through t('namespace.key')
from useTranslation():
const { t } = useTranslation();
return <Button>{t('common.save_changes')}</Button>;
Keys are namespaced by surface, so each feature owns its strings: common.* for reusable actions
(cancel, save_changes, copy), nav.* for navigation, auth.<page>.* for the auth screens,
account.* for settings, validation.* for form messages, and errors.* for AppError details keyed
by camelCase errorCode. Reuse common.* before inventing a key: adding account.cancel_changes when
common.cancel exists creates drift that translators have to keep in sync by hand.
en.json is the source of truth. Every other locale file mirrors its key structure exactly.
Adding a language
Suppose you are adding German (de).
1. Add the locale file
Copy en.json as a starting point so every key is present, then translate the values:
cp frontend/src/shared/i18n/locales/en.json frontend/src/shared/i18n/locales/de.json
Keep the keys identical to en.json. The sync test (below) fails if a key is missing.
2. Register the locale in i18n.ts
Add a dynamic import to the loaders map in i18n.ts, append the code to SUPPORTED_LOCALES, and add its
label to LOCALE_LABELS. Keep the normal locale lazy: a static import here would put it in the initial bundle.
The Locale type follows the supported list.
The language switcher reads the supported list and labels, so it needs no separate entry. Add the new language
to the completeness checks in locales.test.ts as well. Those checks should cover both missing and extra keys,
including every server-validation translation referenced by VALIDATION_CODE_MAP.
Startup recovery has its own small resources under frontend/src/app/ so it can render when normal translations
fail to download. Extend those resources and the recovery language selection if the new language needs a native
retry screen; otherwise recovery falls back to English. Keep that copy independent of the lazy locale files.
Run pnpm test, then verify both a normal language change and recovery after a failed locale download.
Interpolation and markup
Pass variables as the second argument; i18next substitutes {{name}} placeholders:
t('dashboard.welcome', { name });
// en.json: "welcome": "Welcome, {{name}}"
// de.json: "welcome": "Willkommen, {{name}}"
For strings that contain markup, use <Trans> rather than concatenating JSX, so translators can
reorder fragments freely:
<Trans
i18nKey="account.email_verified_html"
values={{ email }}
components={{ strong: <strong /> }}
/>
Error message fallbacks
AppError falls back to t('errors.request_failed', { status }) when the server returns no
ProblemDetails body (a network failure, or a 5xx with no payload). That key must exist in every
locale. For known error codes the lookup is errors.{camelCase(errorCode)}; how the typed client
resolves them is covered in the typed API client.
Checklist
- Copied
en.jsontolocales/<code>.jsonand translated the values, keeping keys identical. - Imported the file in
i18n.tsand added it toresources. - Appended the code to
SUPPORTED_LOCALESand added aLOCALE_LABELSentry. - Added a
<code>.jsoncompleteness block tolocales.test.ts. -
pnpm testpasses (no missing keys, validation keys resolve). -
pnpm dev, then toggle the language switcher: every visible string changes, including a forced 400 error toast.
See the frontend overview for how features and shared modules fit together.