5.5 KiB
Internationalization (i18next)
The v2 apps (starting with apps/compliance-portal) localize with i18next. 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/i18ntranslator used byapps/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 |
| Routes and resource folders | contrib/claude/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.
// 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"
}
}
// 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 thanroutes.tsfiles. A folder without aroutes.tsdoes 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 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.
import { useTranslation } from "react-i18next";
export function MeasuresPage() {
const { t } = useTranslation();
return (
<section>
<h1 className="text-6 text-sand-12">{t("measures.title")}</h1>
<p className="text-sand-11">{t("measures.count", { count })}</p>
</section>
);
}
Do / don't: keys, not source strings
// 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.
// 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 (which wraps Intl.DateTimeFormat / Intl.NumberFormat with the current language) or Intl directly when outside a translation string.
// 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);
// 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/helpersformats a date for display using the runtime locale.formatDuration(duration, t)from@probo/i18nformats a supported ISO 8601 duration. Pass the i18nexttfunction so pluralization remains in the catalog; it usesduration.minandduration.hourwithcount.fileSize(bytes, t)from@probo/i18nformats a byte count using the translatedsize.B,size.KB,size.MB,size.GB, andsize.TBunits.
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.