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) anden. 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.nlis the source of truth for key existence; CI fails ifenmisses a key present innlor 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 ofIntl.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-inIntlAPIs. 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 jsonbe.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.