Skip to content
Slicekit

Frontend

Adding a language

Add a new language to the SPA, where translation namespaces live, and how strings are looked up.

View .md
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.json to locales/<code>.json and translated the values, keeping keys identical.
  • Imported the file in i18n.ts and added it to resources.
  • Appended the code to SUPPORTED_LOCALES and added a LOCALE_LABELS entry.
  • Added a <code>.json completeness block to locales.test.ts.
  • pnpm test passes (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.

Live demo

Explore the running app

No sign-up. Pick an account to open the demo with its credentials prefilled, or open it and sign in yourself.

Open without signing in

Credentials are prefilled. Shared password Slicekit-Demo-2026!.