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

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.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.