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
Everything for translations sits under frontend/src/shared/i18n/:
frontend/src/shared/i18n/
i18n.ts // i18next init, SUPPORTED_LOCALES, LOCALE_LABELS
index.ts // public re-exports
LanguageSwitcher.tsx // the header dropdown
errorCodeMap.ts // FluentValidation code -> i18n key
locales.test.ts // keeps locales in sync
locales/
en.json // the source of truth
nl.json
i18n.ts initialises i18next with every locale bundled at build time (no async loading). The detected
locale is persisted in localStorage under slicekit.locale, falling back to the browser’s navigator
language, then to en:
export const SUPPORTED_LOCALES = ['en', 'nl'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const LOCALE_LABELS: Record<Locale, string> = {
en: 'English',
nl: 'Nederlands',
};
<LanguageSwitcher /> is mounted in both the public layout and the authenticated header. It maps over
SUPPORTED_LOCALES, labels each entry with LOCALE_LABELS, and calls i18n.changeLanguage(locale).
It renders nothing when only one locale is configured, so the dropdown appears the moment you add a
second language.
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
Import the file, add it to the i18next resources, append the code to SUPPORTED_LOCALES, and give it
a label:
import en from './locales/en.json';
import nl from './locales/nl.json';
import de from './locales/de.json'; // new
export const SUPPORTED_LOCALES = ['en', 'nl', 'de'] as const;
export const LOCALE_LABELS: Record<Locale, string> = {
en: 'English',
nl: 'Nederlands',
de: 'Deutsch', // new
};
void i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
nl: { translation: nl },
de: { translation: de }, // new
},
fallbackLng: 'en',
supportedLngs: SUPPORTED_LOCALES,
// ...
});
Locale is derived from SUPPORTED_LOCALES, so LOCALE_LABELS and the switcher stay type-checked
against the new code automatically.
3. The language switcher wires itself up
No change needed. LanguageSwitcher.tsx reads SUPPORTED_LOCALES and LOCALE_LABELS, so the new
entry shows up in the dropdown as soon as it is registered.
4. Keep the locale in sync
locales.test.ts walks every key in en.json and asserts each other locale contains it. Wire the new
locale into the completeness check next to nl:
import de from './locales/de.json';
describe('de.json completeness', () => {
const enKeys = collectKeys(en);
const deKeys = new Set(collectKeys(de));
it.each(enKeys)('de.json has key "%s"', (key) => {
expect(deKeys.has(key)).toBe(true);
});
});
The same file also checks that every i18n key referenced by VALIDATION_CODE_MAP (the FluentValidation
code to key map in errorCodeMap.ts) resolves in each locale, so server-driven validation messages
cannot ship half-translated. Run it with pnpm test.
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.