Skip to content

ADR 021 Internationalization Architecture

Claude product-architect (Opus 4.6) edited this page Mar 16, 2026 · 1 revision

ADR-021: Internationalization Architecture

Status

Accepted

Context

Cornerstone is used by German-speaking homeowners managing construction projects. The application needs to support multiple display languages (starting with English and German) and a configurable display currency. Key considerations:

  1. User base: 1-5 users per instance, all likely sharing the same language, but individuals may prefer different languages.
  2. Content scope: All UI text is static (labels, buttons, messages, status names). User-generated content (work item names, notes, diary entries) is NOT translated.
  3. Currency: The display currency is a deployment-level setting (all users in one instance use the same currency), not a per-user preference.
  4. Existing patterns: The codebase already has a ThemeContext + user_preferences pattern for per-user settings with localStorage hydration and server sync. Formatters in client/src/lib/formatters.ts hardcode 'en-US' locale and 'EUR' currency.
  5. Server errors: Error responses use machine-readable ErrorCode strings (e.g., NOT_FOUND, VALIDATION_ERROR). Human-readable messages are for debugging, not display.
  6. Dependency policy: No native binary dependencies on the client side (rules out SWC-based tooling).

Alternatives Considered

Option Pros Cons
react-i18next Industry standard, React hooks/components, namespace support, interpolation, pluralization, active maintenance, pure JS Adds ~40KB (gzipped ~12KB) to bundle
react-intl (FormatJS) Strong ICU MessageFormat support, built-in number/date formatting Heavier bundle, more complex API for simple string translations, ICU syntax learning curve
Custom solution Zero dependencies, full control Reinvents pluralization, interpolation, namespace loading; maintenance burden

Decision

Use react-i18next (built on i18next) for client-side internationalization with the following design:

1. Library Choice: react-i18next

react-i18next is the most widely adopted React i18n library. It provides:

  • useTranslation() hook for function components
  • Trans component for JSX interpolation (bold text, links in translated strings)
  • Namespace support to split translations by feature domain
  • Built-in interpolation ({{count}}, {{name}}) and pluralization
  • Pure JavaScript -- no native binaries

2. Translation Loading: Static Imports (Bundled)

Translations are imported statically and bundled with the application. With only 2 languages and approximately 800 keys per language, the total payload is roughly 20KB gzipped. This eliminates:

  • HTTP requests for translation files at runtime
  • Loading states / race conditions between i18n init and first render
  • Need for a translation file server endpoint

3. Namespace Structure

One JSON file per feature domain per language:

client/src/i18n/
  index.ts              # i18next initialization
  en/
    common.json         # Shared: nav, buttons, status labels, validation messages
    auth.json           # Login, setup, session
    dashboard.json      # Dashboard cards, headings
    workItems.json      # Work item CRUD, statuses, fields
    householdItems.json # Household item CRUD, statuses, fields
    budget.json         # Budget lines, invoices, sources, subsidies
    schedule.json       # Gantt, calendar, milestones, dependencies
    diary.json          # Diary entries, types, export
    documents.json      # Paperless integration, document links
    settings.json       # Profile, user management, manage page
    errors.json         # Client-side translations of ErrorCode values
  de/
    (same structure)

4. Translation Key Conventions

  • Flat dot-notation within each namespace: nav.project, button.save, status.in_progress
  • Prefixes by UI context: nav.*, button.*, label.*, placeholder.*, status.*, toast.*, heading.*, empty.*, confirm.*
  • Pluralization uses i18next's built-in _one/_other suffix convention
  • Interpolation: {{variableName}} syntax

5. Locale Detection and Storage

Detection order (i18next languageDetector):

  1. Server preference (via existing user_preferences API, key locale)
  2. localStorage key locale (instant hydration before server response)
  3. Browser navigator.language (mapped: de* -> de, everything else -> en)
  4. Fallback: en

Storage: Mirrors the ThemeContext pattern exactly:

  • LocaleContext provides locale, resolvedLocale, setLocale(), syncWithServer()
  • localStorage for instant hydration on page load (no flash of wrong language)
  • Server sync via existing preference API (key: 'locale', value: 'en' | 'de' | 'system')
  • On auth, LocaleServerSync component (paralleling ThemeServerSync) calls syncWithServer()

6. Locale Preference Values

The locale preference uses the same tri-state pattern as theme:

Value Meaning
'en' Force English
'de' Force German
'system' Auto-detect from browser navigator.language

7. Currency Configuration

Currency is a deployment-level setting, not a per-user preference:

  • Environment variable: CURRENCY (default: EUR)
  • Validation: Must be a valid 3-letter ISO 4217 currency code
  • Exposed via: GET /api/config (public endpoint, no auth required)
  • Client consumption: Fetched once at app startup, stored in LocaleContext, passed to Intl.NumberFormat

8. Formatter Localization

All formatters in client/src/lib/formatters.ts gain locale and currency parameters:

// Before
formatCurrency(amount: number): string
formatDate(dateStr: string | null | undefined, fallback?: string): string

// After
formatCurrency(amount: number, locale: string, currency: string): string
formatDate(dateStr: string | null | undefined, locale: string, fallback?: string): string

A useFormatters() hook wraps these with the current locale and currency from LocaleContext, so components call formatCurrency(1234) without passing locale/currency explicitly.

9. Error Translation Strategy

Server error messages remain in English (they are developer-facing debug strings). The client translates errors for display:

  1. API error responses contain error.code (e.g., NOT_FOUND, VALIDATION_ERROR)
  2. The errors.json namespace maps each ErrorCode to a translated message
  3. A translateError(code: ErrorCode, t: TFunction) utility function looks up the translation
  4. Toast notifications and error banners use the translated message

10. Public Config Endpoint

GET /api/config

Returns deployment-level configuration that the client needs but that is not sensitive:

{
  "currency": "EUR"
}

This endpoint is public (no auth required) because the client needs the currency before login (e.g., the setup page may show currency-formatted values in the future). It is added to the unprotected routes list alongside /api/health.

11. No Schema Changes

Locale is stored using the existing user_preferences table (key: locale, value: 'en' | 'de' | 'system'). The PreferenceKey type in shared/src/types/preference.ts gains the 'locale' union member.

Consequences

What becomes easier

  • Adding new languages: create a new directory under client/src/i18n/ with translated JSON files, add the locale code to the supported locales list
  • Consistent formatting: all date/currency/number formatting flows through locale-aware Intl APIs
  • Currency flexibility: deploying for CHF, USD, or any other currency requires only setting CURRENCY=CHF
  • Error messages: users see translated error messages instead of English developer strings

What becomes more difficult

  • Every new UI string must be added to both en/ and de/ translation files (cannot just hardcode text)
  • Formatter calls gain complexity (though the useFormatters() hook hides this)
  • Bundle size increases by approximately 20KB gzipped (translations) + 12KB gzipped (react-i18next)
  • Testing: components that render translated text need i18next initialized in the test environment

Future extensibility

  • Additional languages: add a new directory, no code changes needed
  • Server-side translations (e.g., PDF export in user's language): i18next can run on Node.js, but this is deferred
  • RTL languages: would require CSS adjustments but i18next supports RTL detection

Clone this wiki locally