08-portals-and-i18nwave: W5filled22 citations

i18n — Multi-Lingual Interface (EN/DE/FR)

Audiences: developer, internal, clinical-buyer

i18n — Multi-Lingual Interface (EN/DE/FR)

Three locales (English, German, French), 102 JSON namespaces per locale, locale-prefixed URLs, and a hard zero-hardcoded-strings rule enforced in CI.

Business Purpose

Dudoxx HMS is sold across the DACH region and Francophone markets — German is the primary clinical-buyer language, French is required for partner clinics, and English is the developer/investor default. A patient using /de/portal/patient/dashboard reads the same component a developer ships in /en/portal/patient/dashboard; the translation layer is the only difference. This means a feature ships once and goes live in three markets the moment translators commit a key.

The discipline ("zero hardcoded strings", enforced via pnpm i18n:ci-check) prevents the common SaaS failure mode where a hot fix introduces an English-only banner that lives in production for months. Every PR is gated on parity across all three locales.

Audiences

  • Investor: shows the market-ready posture — DACH (DE), French-speaking Switzerland and Belgium, and English fallback — without per-market product forks.
  • Clinical buyer (doctor/nurse/receptionist): experience the entire UI in their working language; the locale is part of the URL, so a bookmark or email link preserves it.
  • Developer/partner: explains the t('namespace.key') pattern, where to add new keys, and how the build fails when a key is missing from any locale.
  • Internal (ops/support): how to debug a missing-translation report and where the fallback chain leads (locale → default-locale en → fallback string).

Architecture

Locale-prefixed URLs (localePrefix: 'always')

ddx-web/src/i18n/routing.ts:6-10 configures next-intl with localePrefix: 'always': every URL carries /en/…, /de/…, or /fr/…. A request to /portal/doctor (no prefix) is redirected by intlMiddleware to /<defaultLocale>/portal/doctor. The matcher in ddx-web/src/proxy.ts:323-327 excludes API routes, static assets, and any path with a dot (file extension).

The three locales are declared in ddx-web/src/lib/i18n/types.ts:1-3:

typescript
export const locales = ['en', 'fr', 'de'] as const;

102 namespaces per locale

Each locale lives at ddx-web/src/locales/<locale>/<namespace>.json. As of the audit (base SHA 09e374b3):

LocaleNamespace files
en102
de102
fr102

The file count per locale MUST equal the length of the namespaces const array in ddx-web/src/lib/i18n/types.ts — at this audit both are exactly 102. A namespace JSON on disk that is NOT registered in namespaces[] (or vice-versa) is a runtime drift: the CI check pnpm i18n:ci-check and pnpm typecheck both PASS without the registry entry, but a missing entry surfaces at render as a MISSING_MESSAGE error. Every new namespace MUST be added to namespaces[] in lockstep with creating its three locale files.

Namespaces are role-scoped (patient, doctor, nurse, receptionist, accountant, admin, orgAdmin, superAdmin, staff, clinicAdmin) or feature-scoped (auth, forms, appointments, messenger, flowagent, assessment, formBuilder, documentGeneration, dashboard, errors, etc.). The canonical list lives at ddx-web/src/lib/i18n/types.ts (the namespaces const).

Server- vs Client-Component split

ContextHookImport
Server Component (page, layout)getTranslationsnext-intl/server
Client Component ('use client')useTranslationsnext-intl
Route Handler / Server ActiongetTranslationsnext-intl/server

Importing useTranslations in a Server Component crashes; importing getTranslations in a Client Component is impossible (different module). Build-time TypeScript catches both.

The fallback chain

ddx-web/src/lib/i18n/config.ts:74-125 defines resolveNamespace():

  1. Try the requested locale's namespace file.
  2. On failure (file missing or load error), fall back to defaultLocale = 'en'.
  3. If even en fails: throw in development (loud), return empty object in production (silent — UI shows the key path).
  4. handleIntlError() at ddx-web/src/lib/i18n/config.ts:16-44 converts MISSING_MESSAGE and INSUFFICIENT_PATH errors into structured logger.warn calls instead of stack traces. In dev, the missing key is rendered as [namespace.key] so it's visible in the UI; in production the bare key is shown.

Locale timezone & formats

ddx-web/src/lib/i18n/config.ts:154-184 pins timeZone: 'Europe/Berlin' and registers named formats (dateTime.short, dateTime.long, number.precise). All format.dateTime() calls inherit these — there is no per-call format option to worry about.

Tech Stack & Choices

LayerChoiceWhy
Librarynext-intl ^4.8.2First-class App Router support; localePrefix: 'always'; integrates with requestLocale for Server Components.
StorageOne JSON file per (locale, namespace)Translators edit one file at a time; merge conflicts stay narrow.
RoutinglocalePrefix: 'always'URL is the truth; share-able links carry locale; no client-side locale switching ambiguity.
Validationpnpm i18n:ci-checkCI-gated; missing key in any locale fails the build.
Reverse lookupSearch locale JSON first, then TSXLocating a UI string by its visible value takes seconds (see ~/.claude/rules/i18n.md reverse-lookup workflow).

Rejected: a single messages.json per locale (would be unmaintainable and slow to load), in-database translations (would couple deploys to data migrations), client-side locale auto-detection (would conflict with localePrefix: 'always' and break SEO).

Data Flow

  1. Browser hits https://acm.dudoxx.com/de/portal/doctor/dashboard.
  2. proxy.ts:248-319 strips the locale prefix for protected-route classification, but the URL retains it. intlMiddleware (from createIntlMiddleware(routing) at ddx-web/src/proxy.ts:23) propagates the locale to the request context.
  3. The page Server Component awaits params (Next.js 16) and calls:
    typescript
    const t = await getTranslations('doctor');
    return <h1>{t('pageTitles.dashboard')}</h1>;
    
  4. Behind the scenes, getRequestConfig at ddx-web/src/lib/i18n/config.ts:145-185 runs on every request: requestLocale is awaited, validated, and the full message tree is loaded via loadMessages(locale) (config.ts:127-136).
  5. Client Components inside the page tree call useTranslations('doctor') — the same messages were serialized once into the request boundary and are available without an extra fetch.
  6. Missing key → handleIntlError logs a structured warning + the UI shows [doctor.foo] in dev (or foo in production).

Implicated Code

  • ddx-web/src/i18n/routing.ts:6-10defineRouting with localePrefix: 'always'.
  • ddx-web/src/i18n/request.ts:1-2 — request config indirection (re-exports lib/i18n/config).
  • ddx-web/src/lib/i18n/config.ts:74-125resolveNamespace and the locale → default → empty fallback chain.
  • ddx-web/src/lib/i18n/config.ts:127-136loadMessages (all namespaces in parallel).
  • ddx-web/src/lib/i18n/config.ts:145-185getRequestConfig entry: locale validation, timezone, formats, error handler, fallback resolver.
  • ddx-web/src/lib/i18n/config.ts:16-44handleIntlError (warn-not-throw on missing keys).
  • ddx-web/src/lib/i18n/types.ts:1-3 — the canonical locales list.
  • ddx-web/src/proxy.ts:23createIntlMiddleware(routing) instantiation; runs for all non-API, non-static routes via the matcher at ddx-web/src/proxy.ts:323-327.
  • ddx-web/src/proxy.ts:115-116LOCALE_PATTERN derived from routing.locales.
  • ddx-web/src/locales/en/, ddx-web/src/locales/de/, ddx-web/src/locales/fr/ — the 102 namespace files per locale.
  • ddx-web/src/lib/i18n/types.ts — the namespaces const array (length 102); a new namespace MUST be registered here or it throws MISSING_MESSAGE at render despite green CI.
  • ddx-web/src/components/portal/createRoleLayout.tsx:33-40 — uses getTranslations for brand name + role badge label.

Operational Notes

  • Reverse-lookup workflow (MANDATORY) — never start a "fix this UI string" investigation by reading page.tsx. Grep the literal in src/locales/, get the key, grep the key in src/ to find every usage. Full workflow in ~/.claude/rules/i18n.md.
  • Adding a new key — add to all three locale files. The CI command pnpm i18n:ci-check fails if any locale is missing the key. Local check: pnpm i18n:analyze-src (scans TSX for hardcoded strings).
  • Namespace boundaries — keep keys close to their role surface (doctor.*, patient.*) and not in common.* unless the string genuinely is shared. common.* bloat is the single most common source of namespace conflicts.
  • useTranslations in a Server Component — silent until the page is requested, then hard crash. Reverse: getTranslations in a Client Component → import error at build.
  • Locale missing from URL — proxy redirects /foo/<defaultLocale>/foo via the next-intl middleware. Bookmarks always carry the locale because of localePrefix: 'always'.
  • Brand name resolutioncreateRoleLayout resolves the brand name from a configured namespace (common, admin, or superAdmin) via resolveFromNamespace at ddx-web/src/components/portal/createRoleLayout.tsx:33-40. Adding a new role config with a brand key requires the key to exist in all three locales of that namespace.
    i18n — Multi-Lingual Interface (EN/DE/FR) — Dudoxx Docs | Dudoxx