# Internationalization (i18next) The v2 apps (starting with `apps/compliance-portal`) localize with [i18next](https://www.i18next.com/). Translations are **key-based**: code references a stable key, and each locale supplies the human-readable string in a JSON catalog. Catalogs live in `_locales/` folders colocated with routes. > This differs from the legacy `@probo/i18n` translator used by `apps/console`, where the **English source string itself is the key** (`__("Save changes")`). New code uses i18next with explicit keys; do not copy the source-string-as-key pattern into v2 apps. ## Related guides | Topic | Guide | |-------|--------| | Where `_locales/` folders live in the tree | [`contrib/claude/app-arborescence.md`](app-arborescence.md#_locales-folder) | | Routes and resource folders | [`contrib/claude/app-arborescence.md`](app-arborescence.md) | ## Catalog files - One JSON file **per locale**, named by its BCP 47 locale tag: `en-US.json`, `fr-FR.json`. - The **filename is the locale** — there is no locale field inside the file; the file is the namespace's catalog for that locale. - Keys are stable, descriptive identifiers (not the English text). Nest by feature/component to avoid collisions. ```jsonc // pages/organizations/measures/_locales/en-US.json { "measures": { "title": "Measures", "empty": "No measures yet", "actions": { "create": "New measure" }, "count_one": "{{count}} measure", "count_other": "{{count}} measures" } } ``` ```jsonc // pages/organizations/measures/_locales/fr-FR.json { "measures": { "title": "Mesures", "empty": "Aucune mesure pour le moment", "actions": { "create": "Nouvelle mesure" }, "count_one": "{{count}} mesure", "count_other": "{{count}} mesures" } } ``` ## Where catalogs live `_locales/` is colocated with a `routes.ts`, at the folder that **names a resource**. The constraint is exact: - **No more `_locales/` folders than `routes.ts` files.** A folder without a `routes.ts` does not get its own `_locales/`; its strings belong to the nearest ancestor resource that has one. - Resource boundaries own their translations: `organizations/routes.ts` → `organizations/_locales/`; `organizations/measures/routes.ts` → `organizations/measures/_locales/`. See [app-arborescence.md](app-arborescence.md#_locales-folder) for the folder-tree examples. ## Using translations in components Read translations with the i18next hook and a key. Keep keys close to where they are defined (the feature namespace), and pass interpolation values as the second argument. ```tsx import { useTranslation } from "react-i18next"; export function MeasuresPage() { const { t } = useTranslation(); return (

{t("measures.title")}

{t("measures.count", { count })}

); } ``` ### Do / don't: keys, not source strings ```tsx // Bad — English source string as the key (legacy @probo/i18n pattern) __("No measures yet"); // Good — stable key resolved from the locale catalog t("measures.empty"); ``` ### Do / don't: no string building Never assemble translated sentences by concatenation — it breaks word order in other languages. Use interpolation and pluralization keys instead. ```tsx // Bad — concatenation `${count} ${t("measures.unit")}`; // Good — pluralized key with interpolation t("measures.count", { count }); ``` ## Formatting dates, numbers, and currency Locale-aware formatting is **presentation** and must follow the active locale — never hand-format with string templates or hardcoded separators. Use i18next's [`Intl`-based formatting](https://www.i18next.com/translation-function/formatting) (which wraps `Intl.DateTimeFormat` / `Intl.NumberFormat` with the current language) or `Intl` directly when outside a translation string. ```tsx // i18next interpolation formatters — locale comes from the active language t("measures.updatedAt", { date: updatedAt, formatParams: { date: { dateStyle: "medium" } } }); t("invoice.total", { amount, val: amount, formatParams: { val: { style: "currency", currency: "EUR" } } }); // Outside a translation string — Intl directly with the active locale const { i18n } = useTranslation(); new Intl.NumberFormat(i18n.language, { style: "percent" }).format(ratio); ``` ```tsx // Bad — hand-rolled, locale-blind formatting `${(ratio * 100).toFixed(0)}%`; `${day}/${month}/${year}`; ``` ### Shared formatting helpers Use the shared formatting helpers instead of duplicating their parsing and unit-selection logic: - `formatDate(dateInput)` from `@probo/helpers` formats a date for display using the runtime locale. - `formatDuration(duration, t)` from `@probo/i18n` formats a supported ISO 8601 duration. Pass the i18next `t` function so pluralization remains in the catalog; it uses `duration.min` and `duration.hour` with `count`. - `fileSize(bytes, t)` from `@probo/i18n` formats a byte count using the translated `size.B`, `size.KB`, `size.MB`, `size.GB`, and `size.TB` units. ```tsx import { fileSize, formatDuration } from "@probo/i18n"; import { formatDate } from "@probo/helpers"; const { t } = useTranslation(); formatDate(document.createdAt); formatDuration(task.timeEstimate, t); fileSize(attachment.size, t); ``` ## Loading catalogs Catalogs are loaded into i18next per locale at app startup (or lazily per route). Because each `_locales/` folder maps to a resource segment, catalogs can be code-split alongside the route bundle that needs them — keep a catalog scoped to the feature it serves rather than one global megafile.