Skip to content

ADR-0007: Internationalization (i18n) Strategy for Dutch and English UI & Field Labels ​

  • Status: Active
  • Date: 2026-09-24
  • Deciders: Project maintainer, Theming/UI agent
  • Related: ADR-0002, ADR-0003, ADR-0005

Context ​

The first clubs are Dutch; their spreadsheets use Dutch headers (Merk, Serienr, Verzekerde waarde, Waar). Some members, conductors, and future clubs prefer English. Beyond static UI text we have dynamic, tenant-defined content (custom categories, renamed roles) and server messages (validation errors, emails/link pages) that must be localised too.

Lightweight constraint: no large i18n framework with runtime message compilers if avoidable.

Decision ​

1. Locales ​

  • Supported: nl (default, fallback) and en. Adding a locale must require only a new dictionary file plus a line in the locale registry.
  • Resolution order per request: user preference → tenant default locale → Accept-Language → nl.
  • <html lang> always reflects the resolved locale.

2. Keys, not strings ​

  • No user-facing literal strings in components or API handlers. Everything goes through t('namespace.key', params).
  • Keys are dot-separated, lower_snake segments, namespaced by feature: assets.field.serial_number, errors.assets.serial_duplicate, perm.assets.edit.label.
  • Dictionaries: locales/{locale}/{namespace}.json, flat keys. nl is the source of truth for key existence; CI fails if en misses a key present in nl or vice versa, or if a key is referenced in code but defined nowhere.
  • Messages use a minimal ICU subset: {name} interpolation and {count, plural, one {…} other {…}} implemented on top of Intl.PluralRules. No full ICU runtime unless a later ADR requires it.

3. Formatting via Intl ​

  • Numbers, currency (Intl.NumberFormat(locale, {style:'currency', currency: tenant.currency})), dates, and relative times use built-in Intl APIs. No moment/date-fns locale bundles.
  • Money is formatted from integer cents at the edge only.

4. Errors across the API boundary ​

  • The API returns stable error codes and structured params, never prose: { "error": { "code": "assets.serial_duplicate", "params": { "serial": "X123" } } }.
  • The UI translates errors.<code>. Server-rendered pages translate server-side with the same dictionaries.

5. Dynamic, tenant-defined content ​

  • System-defined entities (default categories, template roles, statuses, permission labels) store an i18n key (label_key).
  • Tenant-defined or renamed entities store a localised map: label_i18n jsonb e.g. {"nl":"Slagwerk","en":"Percussion"}. Display rule: requested locale → tenant default locale → any available value.
  • DB enum values (club, private, checked_out) are never shown raw; each maps to a key.
  • Generated labels (e.g. anonymized member names, ADR-0005) are rendered from keys at display time.

6. Data import headers ​

  • Import mapping recognises both Dutch and English header synonyms (Merk|Brand, Serienr|Serienummer|Serial, Verzekerde waarde|Insured value, Waar|Location). Synonym lists live alongside dictionaries (locales/{locale}/import_synonyms.json).

Consequences ​

Positive ​

  • Adding a third language (e.g. German, French for Belgian clubs) is mechanical.
  • CI prevents missing translations from reaching production.
  • Error codes decouple API from UI language and are testable.

Negative / trade-offs ​

  • Developers must add keys to two files for every string (mitigated by a dev-mode missing-key overlay and CI check).
  • Tenant-entered content is only as translated as the tenant makes it.

Follow-ups ​

  • Pseudo-locale (en-XA, accented & lengthened strings) in dev to catch hard-coded text and layout overflow.

Released under the GNU AGPL-3.0-or-later.