Files
probo/contrib/claude/i18n.md
Jonathan a7ff5f07bc Add react i18next to console
Signed-off-by: Jonathan <contact@grafikart.fr>
2026-07-27 11:52:31 +02:00

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/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.

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 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.tsorganizations/_locales/; organizations/measures/routes.tsorganizations/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/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.
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.