Internationalization (i18n)
How Bondery loads, types, and validates translated copy across apps.
User-facing strings live in @bondery/translations. Each app calls generated namespace hooks — not generic t(key, { ns }) — so TypeScript can autocomplete keys and prefixes.
Locales
| Code | Language | Role |
|---|---|---|
en | English | Reference locale (DEFAULT_LOCALE) |
cs | Czech | |
de | German |
Source of truth: packages/schemas/locale/supported-locales.json. Exported as SUPPORTED_LOCALES from @bondery/schemas/locale.
To add a language, follow Add a new language.
Layout
packages/translations/
manifest.json # namespaces + preload groups
src/locales/{locale}/ # one JSON file per namespaceA namespace is the filename without .json (folders are organizational only). Examples: common, GroupsPage, MobileSettings.
Architecture decision: typed namespace hooks (not selectors)
Phase 0 spike (2026): We evaluated i18next enableSelector: true and selector syntax (t($ => $.AddGroupModal.Title)).
| Approach | Verdict |
|---|---|
| Selectors | Rejected for Bondery: incompatible with our dominant keyPrefix pattern (useT(ns, { keyPrefix }) → t("Title") maps to nested keys). Would require rewriting ~200 call sites to selector form and uncertain next-i18next support. |
| Literal-namespace codegen hooks | Adopted (Phase 1): each namespace gets use{Namespace}Translations(keyPrefix?) with a hardcoded namespace literal so TypeScript only resolves keys within that namespace. |
| Catalog + branch-prefix unions (Phase 2) | Adopted: manifest-aligned Catalog types, generated *Prefix unions, and TranslateFn<NS, Prefix> give compile-time checks on both namespace and keyPrefix. |
Runtime behavior is unchanged (same JSON, preload groups, resourceLoader). Only the hook surface and generated types are stricter.
Type system (Phase 2)
Types are generated from manifest.json + English locale JSON:
| Artifact | Path | Purpose |
|---|---|---|
Catalog | packages/translations/src/generated/i18n/catalog-types.ts | Per-namespace JSON tree interfaces; NamespaceKey = keyof Catalog |
*Prefix unions | packages/translations/src/generated/i18n/catalog-prefixes.ts | Valid object-branch keyPrefix values per namespace (e.g. SettingsPagePrefix) |
TranslateFn | packages/translations/src/i18n-types.ts | TFunction<NS, Prefix> — keys autocomplete under the chosen prefix |
BranchPrefixFor<NS> | same | Alias for a namespace's prefix union |
PrefixedKeys<NS, Prefix> | same | Dot-joined key paths available under a prefix |
| i18next augment | packages/translations/src/generated/i18next-cli/i18next.d.ts | Patched at build time to use Catalog (manifest keys like "GroupsPage", not file paths) |
Codegen order (pnpm run build -w @bondery/translations):
i18next-cli types— lint/status tooling types (path-basedresources.d.ts, kept for CLI only)generate-i18n-catalog.mjs—Catalog, prefix unions, patchesi18next.d.tsgenerate-client-i18n-hooks.mjs— per-app hooks with prefix overloads
Hooks (use these in app code)
Webapp
import { useGroupsPageTranslations, useCommonTranslations } from "@/lib/i18n/generated/hooks";
// With keyPrefix — keys are relative to the branch; prefix is type-checked
const t = useGroupsPageTranslations("AddGroupModal");
t("Title"); // → GroupsPage.AddGroupModal.Title
// Without keyPrefix — use full dotted paths within the namespace
const tPage = useGroupsPageTranslations();
tPage("AddGroupModal.Title");
const tCommon = useCommonTranslations();
tCommon("actions.cancel");Prefix typo → compile error:
useSettingsPageTranslations("DataManagement.VCardImportt"); // not in SettingsPagePrefixServer metadata:
import { getPeoplePageTranslations } from "@/lib/i18n/generated/hooks.server";
const t = await getPeoplePageTranslations();Do not use: useWebTranslations("…"), getWebTranslations("…"), or t(key, { ns: "…" }).
Mobile
import { useMobileSettingsTranslations } from "@/lib/i18n/generated/hooks";
const t = useMobileSettingsTranslations();
t("Title");Do not use: useMobileTranslations() with per-call { ns: "…" }.
Chrome extension
import { useExtensionPopupTranslations } from "@/lib/i18n/generated/hooks";
const t = useExtensionPopupTranslations("LoggedOut");
t("Title");Hooks are regenerated from manifest.json when you run pnpm run build -w @bondery/translations. Each hook hardcodes its namespace and type-checks keyPrefix branches.
Escape hatches (dynamic keys)
Most call sites should use generated hooks and literal keys. Use these only when keys are computed at runtime.
LooseTranslateFn
For utilities that map runtime values to keys (interaction types, slash commands, social tooltips):
import type { LooseTranslateFn } from "@bondery/translations";
import { useInteractionTypesTranslations } from "@/lib/i18n/generated/hooks";
const t = useInteractionTypesTranslations() as LooseTranslateFn;
t(dynamicKey);transT — <Trans> component
next-i18next / react-i18next <Trans> expects a plain TFunction. Cast namespace-scoped t:
import { transT } from "@/lib/i18n/transT";
import { useSettingsPageTranslations } from "@/lib/i18n/generated/hooks";
const t = useSettingsPageTranslations("DataManagement.DeleteAccount");
<Trans t={transT(t)} i18nKey="ConfirmLabel" />optionalPluralFragment
Plural fragments used as interpolation values inside a parent sentence:
import { optionalPluralFragment } from "@/lib/i18n/optionalPluralFragment";
t("Summary", {
skippedDetails: optionalPluralFragment(t, "Skipped_one", skippedCount, { count: skippedCount }),
});Namespaces (i18next best practices)
Follow i18next namespaces:
- Semantic splits:
common,validation,glossary - Per feature/page:
GroupsPage,MobileSettings, … - Preload groups in
manifest.json— load only what a route/screen needs
Follow interpolation best practices:
- Prefer separate keys when grammar varies by interpolated value (e.g. payment type, gender)
- Use
context/ plurals / formatting for locale-specific rules - Reserve interpolation for runtime-only values (user input, timestamps)
Regenerate types and hooks
After editing locale JSON or manifest.json:
pnpm run build -w @bondery/translationsThis rebuilds Catalog types, prefix unions, i18next augmentations, and per-app generated/hooks*.ts.
CI checks
| Command | What it verifies |
|---|---|
pnpm run check:i18n | All i18n CI gates below (umbrella) |
pnpm run check:i18n:structure | Locale file parity, manifest rules, Languages exonyms |
pnpm run check:i18n:types | Generated i18next-cli types are current |
pnpm run check:i18n:usage | Every key used in code (including TypedTrans i18nKey) exists in all locales; cs/de key parity vs en |
pnpm run check:i18n:lint | No unexpected hardcoded UI strings (webapp + mobile) |
API error code translations use pnpm run check:api-errors:translations (part of check:api-errors), not check:i18n.
For rich text with embedded links or markup, prefer TypedTrans (webapp) with a single translation key and component placeholders — see apps/webapp/src/lib/i18n/TypedTrans.tsx.
Run these before opening a PR that touches copy.
Related
- Add a new language
packages/translations/README.md— manifest layout and mirror tooling