# 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}`;
```
Reuse `@probo/helpers` only for its **non-presentational** date utilities — parsing and `` shaping (`parseDate`, `toDateInput`, `todayAsDateInput`). Do not use its v1 `formatDate` / `formatDuration` helpers in v2 apps: they predate i18next and thread the legacy `@probo/i18n` `__` translator. Format for display through i18next / `Intl` instead.
## 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.