i18n — Multi-Lingual Interface (EN/DE/FR)
Audiences: developer, internal, clinical-buyer
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:
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):
| Locale | Namespace files |
|---|---|
| en | 102 |
| de | 102 |
| fr | 102 |
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
| Context | Hook | Import |
|---|---|---|
| Server Component (page, layout) | getTranslations | next-intl/server |
Client Component ('use client') | useTranslations | next-intl |
| Route Handler / Server Action | getTranslations | next-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():
- Try the requested locale's namespace file.
- On failure (file missing or load error), fall back to
defaultLocale = 'en'. - If even
enfails: throw in development (loud), return empty object in production (silent — UI shows the key path). handleIntlError()atddx-web/src/lib/i18n/config.ts:16-44convertsMISSING_MESSAGEandINSUFFICIENT_PATHerrors into structuredlogger.warncalls 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
| Layer | Choice | Why |
|---|---|---|
| Library | next-intl ^4.8.2 | First-class App Router support; localePrefix: 'always'; integrates with requestLocale for Server Components. |
| Storage | One JSON file per (locale, namespace) | Translators edit one file at a time; merge conflicts stay narrow. |
| Routing | localePrefix: 'always' | URL is the truth; share-able links carry locale; no client-side locale switching ambiguity. |
| Validation | pnpm i18n:ci-check | CI-gated; missing key in any locale fails the build. |
| Reverse lookup | Search locale JSON first, then TSX | Locating 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
- Browser hits
https://acm.dudoxx.com/de/portal/doctor/dashboard. proxy.ts:248-319strips the locale prefix for protected-route classification, but the URL retains it.intlMiddleware(fromcreateIntlMiddleware(routing)atddx-web/src/proxy.ts:23) propagates the locale to the request context.- The page Server Component awaits
params(Next.js 16) and calls:typescriptconst t = await getTranslations('doctor'); return <h1>{t('pageTitles.dashboard')}</h1>; - Behind the scenes,
getRequestConfigatddx-web/src/lib/i18n/config.ts:145-185runs on every request:requestLocaleis awaited, validated, and the full message tree is loaded vialoadMessages(locale)(config.ts:127-136). - 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. - Missing key →
handleIntlErrorlogs a structured warning + the UI shows[doctor.foo]in dev (orfooin production).
Implicated Code
ddx-web/src/i18n/routing.ts:6-10—defineRoutingwithlocalePrefix: 'always'.ddx-web/src/i18n/request.ts:1-2— request config indirection (re-exportslib/i18n/config).ddx-web/src/lib/i18n/config.ts:74-125—resolveNamespaceand the locale → default → empty fallback chain.ddx-web/src/lib/i18n/config.ts:127-136—loadMessages(all namespaces in parallel).ddx-web/src/lib/i18n/config.ts:145-185—getRequestConfigentry: locale validation, timezone, formats, error handler, fallback resolver.ddx-web/src/lib/i18n/config.ts:16-44—handleIntlError(warn-not-throw on missing keys).ddx-web/src/lib/i18n/types.ts:1-3— the canonicallocaleslist.ddx-web/src/proxy.ts:23—createIntlMiddleware(routing)instantiation; runs for all non-API, non-static routes via the matcher atddx-web/src/proxy.ts:323-327.ddx-web/src/proxy.ts:115-116—LOCALE_PATTERNderived fromrouting.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— thenamespacesconst array (length 102); a new namespace MUST be registered here or it throwsMISSING_MESSAGEat render despite green CI.ddx-web/src/components/portal/createRoleLayout.tsx:33-40— usesgetTranslationsfor 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 insrc/locales/, get the key, grep the key insrc/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-checkfails 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 incommon.*unless the string genuinely is shared.common.*bloat is the single most common source of namespace conflicts. useTranslationsin a Server Component — silent until the page is requested, then hard crash. Reverse:getTranslationsin a Client Component → import error at build.- Locale missing from URL — proxy redirects
/foo→/<defaultLocale>/foovia the next-intl middleware. Bookmarks always carry the locale because oflocalePrefix: 'always'. - Brand name resolution —
createRoleLayoutresolves the brand name from a configured namespace (common,admin, orsuperAdmin) viaresolveFromNamespaceatddx-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.
Related Topics
- Multi-Portal Architecture — every portal layout reads brand/role labels via i18n.
- Doctor Portal, Patient Portal,
Nurse Portal, Receptionist Portal —
each consumes its own namespace (
doctor,patient,nurse,receptionist).