Skip to main content
In a spa module, internationalization is per feature. Each feature owns its own dictionary and a generated hook: a translations.json file holding every string keyed by locale, and a use<Name>Translate hook that reads the active language and looks keys up for you. The active language is resolved once, from the ?lang= query param, by the shared useLang hook. You scaffold this pair with talos translation:create. In a spa the generator produces a hook; backend modules get a Translation class instead. The dictionary format is the same, only the consumer differs. See the translation component for the shared engine both build on.

How it works

Translations live next to the feature that uses them, under src/features/<feature>/translations/. The generator writes two files: the dictionary and the hook. The dictionary is written once and never overwritten, so you grow it by hand. The hook wraps the trans/has primitives from @talosjs/utils/trans and binds them to the current language.

The dictionary

translations.json is a tree of keys. Each leaf is an object mapping locale codes to strings. Keys nest freely, so a feature can group its strings however it likes. Strings may contain {{ param }} placeholders that are filled at call time, and a key may carry plural variants selected by a count argument.
translations.json
A few rules govern how a key resolves:
  • Locale leaves. The active language (from useLang) selects which leaf is used. title with lang = "fr" yields "Paramètres".
  • Interpolation. {{ param }} placeholders are replaced by the matching entry in params. greeting with { name: "Ada" } yields "Hello Ada".
  • Pluralization. When you pass count, the resolver picks among sibling keys: _zero when count is 0, _plural when count is plural, and the base entry otherwise. {{ count }} itself is available as an interpolation param. So items with count = 0 yields "No items", count = 1 yields "1 item", and count = 5 yields "5 items".
  • Fallback. When no entry matches a key, the hook returns the key string unchanged, so missing translations surface as the key rather than crashing.

Resolving the language

The active language comes from a single shared hook. It reads the ?lang= search param off the current URL and defaults to en, so ?lang=fr switches the whole app to French without any extra state.
src/shared/hooks/useLang.ts
Because every translation hook routes through useLang, changing the query param re-renders the consuming components in the new language, with no separate language store to keep in sync. See routing for how search params flow through the router.

The generated hook

talos translation:create writes a use<Name>Translate hook per feature. It imports the feature’s translations.json, resolves the language with useLang, and exposes trans and has bound to that language and dictionary.
src/features/settings/translations/useSettingsTranslate.ts

Using it in a component

Call the hook at the top of a component, then translate by key. Pass params for interpolation and count to drive plural selection; use has to branch on whether a key exists.
src/features/settings/components/SettingsHeader.tsx
Switch the rendered language by changing the URL: ?lang=fr flips every string this hook resolves to French, falling back to en when no lang is present.

Working with translations

Keep each feature’s strings in its own translations/translations.json next to the code that uses them, and don’t reach into another feature’s dictionary. The generator writes the dictionary once, so add new keys and locales by hand; it is never regenerated over a populated dictionary by design. Missing keys render as the key itself, which makes a forgotten translation visible in the UI rather than a runtime error, so you can search for raw keys when auditing coverage. Resolve language only through useLang/?lang=, rather than threading a language prop through components or duplicating the logic. For countable strings, always pass count so _zero/_plural selection works, and reference {{ count }} in those entries.