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 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, under src/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.
PieceWhat it does
translations.jsonThe locale dictionary — nested keys whose leaves are { en, fr, … } maps. Written once per feature, never regenerated.
use<Name>Translate.tsA generated hook returning { trans, has }. Resolves the active language via useLang and looks keys up in the dictionary.
useLangShared 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
{
  "title": { "en": "Settings", "fr": "Paramètres" },
  "greeting": { "en": "Hello {{ name }}", "fr": "Bonjour {{ name }}" },
  "items": {
    "_zero": { "en": "No items", "fr": "Aucun élément" },
    "en": "{{ count }} item",
    "_plural": { "en": "{{ count }} items", "fr": "{{ count }} éléments" }
  }
}
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 — 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
import { useSearch } from "@tanstack/react-router";

export const DEFAULT_LANG = "en";

// strict: false opts out of route-specific search typing so it works from any route.
export const useLang = (): string => {
  const search = useSearch({ strict: false }) as { lang?: string };
  return search.lang ?? DEFAULT_LANG;
};
Because every translation hook routes through 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
import { has, trans, type TransDictType, type TransParamsType } from "@ooneex/utils/trans";
import { useLang } from "../../../shared/hooks/useLang";
import dict from "./translations.json";

export const useSettingsTranslate = () => {
  const lang = useLang();
  return {
    // Translates key, interpolating params and pluralizing on count.
    // Returns the key itself when no matching entry exists.
    trans: (key: string, params?: TransParamsType, count?: number): string => {
      const result = trans(dict as TransDictType, key, { lang, params, count });
      return result.found ? result.value : key;
    },
    has: (key: string): boolean => has(dict as TransDictType, key),
  };
};

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
import { useSettingsTranslate } from "../translations/useSettingsTranslate";

export const SettingsHeader = ({ name, count }: { name: string; count: number }) => {
  const { trans, has } = useSettingsTranslate();

  return (
    <header>
      <h1>{trans("title")}</h1>
      <p>{trans("greeting", { name })}</p>
      <span>{trans("items", { count }, count)}</span>
      {has("subtitle") && <small>{trans("subtitle")}</small>}
    </header>
  );
};
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.

Best practices

  • One dictionary per feature. Keep each feature’s strings in its own translations/translations.json next 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 count for plurals. Always pass count for countable strings so _zero/_plural selection works, and reference {{ count }} in those entries.