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 ooneex translation:create. In a spa the generator produces a hook; backend modules get a Translation class instead — same dictionary format, different consumer. See the translation component for the shared engine both build on.
How it works
Translations live next to the feature that uses them, undersrc/features/<feature>/translations/. The generator writes two files: the dictionary and the hook. The dictionary is written once and never overwritten — you grow it by hand; the hook wraps the trans/has primitives from @ooneex/utils/trans and binds them to the current language.
| Piece | What it does |
|---|---|
translations.json | The locale dictionary — nested keys whose leaves are { en, fr, … } maps. Written once per feature, never regenerated. |
use<Name>Translate.ts | A generated hook returning { trans, has }. Resolves the active language via useLang and looks keys up in the dictionary. |
useLang | Shared hook (src/shared/hooks/useLang.ts) that reads ?lang= from the URL, defaulting to en. Scaffolded with the first translation hook. |
trans(key, params?, count?) | Returns the translated string, interpolating {{ params }} and pluralizing on count. Falls back to the key itself when no entry matches. |
has(key) | Returns whether the dictionary contains an entry for key. |
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
- Locale leaves. The active language (from
useLang) selects which leaf is used.titlewithlang = "fr"yields"Paramètres". - Interpolation.
{{ param }}placeholders are replaced by the matching entry inparams.greetingwith{ name: "Ada" }yields"Hello Ada". - Pluralization. When you pass
count, the resolver picks among sibling keys:_zerowhencountis0,_pluralwhencountis plural, and the base entry otherwise.{{ count }}itself is available as an interpolation param. Soitemswithcount = 0yields"No items",count = 1yields"1 item", andcount = 5yields"5 items". - Fallback. When no entry matches a key, the hook returns the key string unchanged — 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
useLang, changing the query param re-renders the consuming components in the new language — there is no separate language store to keep in sync. See routing for how search params flow through the router.
The generated hook
ooneex 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. Passparams for interpolation and count to drive plural selection; use has to branch on whether a key exists.
src/features/settings/components/SettingsHeader.tsx
?lang=fr flips every string this hook resolves to French, falling back to en when no lang is present.
Best practices
- One dictionary per feature. Keep each feature’s strings in its own
translations/translations.jsonnext to the code that uses them — don’t reach into another feature’s dictionary. - Grow dictionaries, don’t overwrite them. The generator writes the dictionary once. Add new keys and locales by hand; never regenerate over a populated dictionary, since it is never overwritten by design.
- Lean on the key fallback. Missing keys render as the key itself, so a forgotten translation is visible in the UI rather than a runtime error — search for raw keys when auditing coverage.
- Drive language from the query param. Resolve language only through
useLang/?lang=; don’t thread a language prop through components or duplicate the logic. - Use
countfor plurals. Always passcountfor countable strings so_zero/_pluralselection works, and reference{{ count }}in those entries.
Related
- Spa overview — how spa modules are organized.
- Spa structure and features — where
translations/lives. - Routing — how search params like
?lang=reach the hook. - Translation component — the shared
trans/hasengine. translation:create— the generator command reference.- Internationalization (advanced) — the model across module types.