Skip to content

Internationalization (i18n)

Patrimoine supports five locales: French (default), English, Spanish, German, and Italian. The frontend uses react-intl and the backend has its own translation layer for error messages.


Code Language BCP47 Tag Default
fr French fr-FR Yes
en English en-US
es Spanish es-ES
de German de-DE
it Italian it-IT

The I18nProvider in src/i18n/I18nProvider.tsx wraps the application and provides the react-intl context.

Each of the five catalogs is its own chunk under assets/locales/, loaded on demand through LOCALE_LOADERS. Until the first one arrives the provider shows a spinner without text, so no message id is ever painted. public/locale-init.js reads the stored language before React starts and preloads that catalog from the locale-chunks meta tag the build writes into index.html, so it downloads in parallel with the entry chunk. Its language list is a hand-kept mirror of LOCALE_LOADERS, checked by locale-init-sync.test.ts. If a catalog fails to load, the provider falls back to fr (which the service worker keeps precached), then to message ids, rather than staying on the spinner.

Locale is resolved in the following priority order:

  1. User profile — locale saved on the user’s account (synced from backend)
  2. localStorage — patrimoine-locale key
  3. navigator.language — browser language preference
  4. fr — fallback default

Translation files live in src/locales/ (fr.json, en.json, es.json, de.json, it.json). All five catalogs contain the same keys (2952 at the time of writing); verify with python3 -c "import json; print(len(json.load(open('src/locales/en.json'))))".

A locale key-parity test (src/__tests__/locale-parity.test.ts) runs in CI to verify that all five files have identical key sets and no empty values, preventing drift between locales. A second test (src/__tests__/locale-icu-parity.test.ts) formats every English message with the real ICU runtime in each locale and fails when a translation drops one of its arguments or leaves a placeholder unresolved.

Use <FormattedMessage> for declarative translation:

import { FormattedMessage } from 'react-intl'
<FormattedMessage id="accounts.balance" />

Use useIntl() for dynamic formatting, toast messages, and conditional logic:

import { useIntl } from 'react-intl'
const intl = useIntl()
const label = intl.formatMessage({ id: 'accounts.balance' })
toast.success(intl.formatMessage({ id: 'accounts.updated' }))

Use ICU plural syntax in locale files for count-dependent messages:

"accounts.count": "{count, plural, =0 {No accounts} =1 {1 account} other {# accounts}}"

Render with values:

intl.formatMessage({ id: 'accounts.count' }, { count })

Never use manual pluralization like `${count} item${count > 1 ? 's' : ''}`.

Store the i18n key as the Zod error message, then render it with <FormattedMessage>:

const schema = z.object({
name: z.string().min(1, 'accounts.nameRequired').max(100, 'accounts.nameTooLong'),
});
// In the error display:
{fieldState.error?.message && (
<p className="text-body text-destructive">
<FormattedMessage id={fieldState.error.message} />
</p>
)}

A LanguageToggle component in the sidebar footer (next to ModeToggle and SkinToggle) cycles through FR → EN → ES → DE → IT with a single click. It uses useLocaleContext() from I18nProvider to update the locale, which persists to localStorage and syncs to the backend.

A full language selector is also available on the Settings page.

The useLocale hook syncs the selected locale with the backend via PATCH /api/v1/auth/me (fire-and-forget). This ensures the user’s locale preference persists across sessions and devices.

The <html lang> attribute is updated dynamically when the locale changes, ensuring correct screen reader behavior and browser language detection.

The I18nProvider calls setFormatLocale(locale) whenever the locale changes. This sets a module-level currentLocale variable in src/lib/format.ts that serves as the default locale for all formatting functions (formatCurrency, formatDateShort, formatDateLong, formatCompactCurrency).

This avoids the need to pass the locale explicitly to every formatting call — the locale is set once on mount and kept in sync on every change.

The intlLocale() function maps internal locale codes to BCP47 tags:

Internal BCP47
fr fr-FR
en en-US
es es-ES
de de-DE
it it-IT

The backend has its own translation layer in app/i18n.py with a translate(key, locale, **kwargs) function. It covers the same five locales: fr, en, es, de and it.

Backend error messages (import errors, auth errors, validation errors) are translated using the user’s locale. If a translation key is not found for the requested locale, the function falls back to French (fr).

from app.i18n import translate
message = translate("import.invalid_format", locale="en")

Formatting utilities in src/lib/format.ts use a module-level currentLocale (set via setFormatLocale() by I18nProvider) as the default locale, so callers do not need to pass the locale explicitly.

Function Purpose Example
setFormatLocale(locale) Sets the module-level locale for all format functions Called by I18nProvider on locale change
setFormatDateFormat(preset) Sets the module-level date format preset Called by FormatPreferencesSync when the user’s profile loads or changes
setFormatNumberFormat(preset) Sets the module-level number format preset Called by FormatPreferencesSync when the user’s profile loads or changes
formatCurrency(amount, currency='EUR', locale?) Full currency formatting “12 500,00 €” (fr) / “$12,500.00” (en)
formatDateShort(date, locale?) Compact date format depends on the user’s date format preset, e.g. “22/07/2026”
formatDateLong(date, locale?) Full date format “22 juillet 2026” (fr) / “22 July 2026” (en)
formatCompactCurrency(value, currency, locale?) Abbreviated currency “12,5 K€” (fr) / “€12.5K” (en)
formatPercent(value, { digits, signed, locale }) The only way to show a percentage (percent units: 12.5 means 12.5 %) “12,5 %” (fr) / “12.5%” (en)
formatPercentage(value) Signed percentage with one decimal; delegates to formatPercent “+12,5 %” (fr) / “+12.5%” (en)
formatAmountPlaceholder(example?, locale?) Example amount for a money input placeholder: two decimals, no grouping “10000,00” (fr) / “10000.00” (en)
compareText(a, b, locale?) Sort comparison for display names: accent- and case-insensitive, digits as numbers “Konto 2” before “Konto 10”
formatRate(value) Rate formatting “12.5” / “0.035”
formatMonthLabel(monthStr, locale?) Month label “Juillet 2026” / “July 2026”
resolveNumberLocale(locale?) Resolves the BCP47 tag to use for number/currency formatting currentNumberFormat’s locale if set, else intlLocale(locale)

The locale parameter is optional on all format functions — when omitted, the module-level currentLocale is used. This is set by I18nProvider via setFormatLocale() on mount and on every locale change.

formatDateShort, formatCurrency, and formatCompactCurrency also consult the user’s independent date_format/number_format display preferences (set via setFormatDateFormat/setFormatNumberFormat, synced from the auth query by FormatPreferencesSync) before falling back to this locale-derived behavior.

These presets are separate from the UI-language locale — a user can keep the app in English while numbers render in the German 1.234,56 style, for example.

formatRate and formatMonthLabel were moved from dashboard-calc.ts to format.ts for consistency.

Chart components must use the user’s locale (and number format preference) for number/currency formatting, not a hardcoded 'fr-FR'. Use resolveNumberLocale() from @/lib/format to get the BCP47 tag:

import { useIntl } from 'react-intl';
import { type Locale } from '@/i18n';
import { resolveNumberLocale } from '@/lib/format';
const intl = useIntl();
const formatter = new Intl.NumberFormat(
resolveNumberLocale(intl.locale as Locale),
{ style: 'currency', currency: 'EUR' }
);

Or use the formatCurrency/formatCompactCurrency utilities directly, passing the current locale.


Account types use a labelId field (an i18n key) defined in src/constants/accountTypes.ts. The label is resolved at render time via useIntl(), keeping the UI fully translatable without hardcoding display strings.


  1. Add the key to all five locale files: src/locales/fr.json, src/locales/en.json, src/locales/es.json, src/locales/de.json, src/locales/it.json
  2. Use <FormattedMessage id="your.key" /> in components, or intl.formatMessage({ id: 'your.key' }) for programmatic access
  3. Ensure the key follows the existing naming convention (dot-separated, lowercase, namespace-prefixed)
  4. Write a message that carries a count as an ICU plural (one and other in en and de; fr, es and it add an explicit =0 branch so a zero reads naturally, such as “Aucun compte supprimé”), never with “(s)” suffixes
  5. Name a create button after its object with the verb rule in web/documentation/i18n/glossary.md: New for top-level objects, Add for items inside a parent