UI Internationalization (i18n)
How the three Next.js apps render their UI in the org's chosen language. Implements P21.
Scope
This document covers UI translation only — the static strings that ship with the application code (nav labels, button text, error messages, empty states, page titles). Tenant-created content translation (exercise names, custom field labels, treatment plan descriptions) is a separate concern handled via translations JSONB columns on content tables; that pattern lands in Layer 10.
The VitePress docs site (apps/docs/) is not in scope. Docs translations are managed in-tree under apps/docs/ro/ with VitePress's native i18n config; they don't share key namespaces, tooling, or workflow with the app translations. Drift between en/ro docs is a docs-team concern, not an app-i18n concern.
Console (apps/console/) is English-only by decision and ships no next-intl wiring at all. It's a platform-superadmin surface with no tenant org in scope; staff working there are platform engineers and operators, not clinic users. Strings are inlined English literals in JSX/server components — no helper, no message catalog, no locale resolution. The conventions in this doc apply to clinic and portal only.
Model: org-driven, cookie-resolved
The locale is per tenant, not per user. Every clinic picks one language_code (e.g., 'en' or 'ro'); every user in that clinic — staff and patients — sees the entire UI chrome in that language. This is intentional:
- Clinics are typically monolingual (a Romanian clinic in Bucharest doesn't have an English-speaking front desk that needs an English toggle).
- Patients see the language they expect for medical interactions in their country.
- Per-user override is supported via
humans.preferred_language(NULL = inherit org), exposed throughPATCH /v1/me. Resolution order inproxy.tsis user preference → orglanguage_code→'en'. Use sparingly — the org-level default is the right call for most users (e.g., an English-speaking specialist in a Romanian clinic can override; the clinic doesn't need to change its setting).
Resolution pipeline
hostname (slug.clinic.example.com)
│
▼
proxy.ts (Clerk + org resolution)
│ fetches GET /v1/public/organizations/resolve?slug=…
│ reads `language_code` from response
│
▼
setOrgCookies → writes `language` cookie (httpOnly: false)
│
▼
i18n/request.ts (per request, server-side)
│ reads `language` cookie via cookies()
│ loads messages/{locale}.json
│
▼
NextIntlClientProvider wraps the app
│
▼
Components: useTranslations() (client) / getTranslations() (server)Fallback. If the cookie is missing or holds an unrecognized value, the app falls back to 'en'. The fallback path is normalized in packages/api-client/src/proxy.ts (SUPPORTED_LOCALES, DEFAULT_LOCALE); both proxy.ts and i18n/request.ts call into the same helper so the fallback rule stays in one place.
Console-specific note. Console has no resolution pipeline — it ships no translations at all. The diagram above applies to clinic and portal only.
Library: next-intl
Each app depends on next-intl (>=4). The library wires three things:
next.config.mjsis wrapped withcreateNextIntlPlugin('./i18n/request.ts').i18n/request.tsexports agetRequestConfigthat returns{locale, messages}per request.app/layout.tsxwraps the tree with<NextIntlClientProvider locale={locale} messages={messages}>and sets<html lang={locale}>.
Components consume:
import { useTranslations } from "next-intl"— client components.import { getTranslations } from "next-intl/server"— server components and route handlers.
File structure
Per app:
apps/{clinic|portal}/
├── i18n/
│ └── request.ts # cookie → locale → merged messages
└── messages/
├── en.json # English (source of truth, app-specific)
└── ro.json # Romanian (app-specific)Console has no i18n/ or messages/ directory — strings are inlined.
Cross-app commons (auth controls, errors, notFound, language switcher) live in a shared package:
packages/i18n/
├── messages/
│ ├── en.json
│ └── ro.json
└── src/index.ts # loadSharedMessages + mergeMessagesClinic and portal deep-merge @workspace/i18n messages with their per-app bundle in i18n/request.ts. Both files use the same natural namespaces (auth, errors, notFound, language) so call sites don't need to know whether a key came from shared or per-app — useTranslations("auth") looks up signOut regardless of which file shipped it. Per-app keys win on conflict, so an app can override a common string when its context demands it (e.g., a softer "Sign out" wording for patients).
Conventions (READ BEFORE EDITING UI)
Every user-facing string goes through the helper
No new hardcoded strings. If you find yourself typing English text in a <button> or <p>, instead:
- Pick a key in
messages/en.jsonunder the appropriate namespace (auth,nav,errors,dashboard, etc.). - Add the same key with the Romanian translation in
messages/ro.json. - Use it in the component:tsx
const t = useTranslations("auth") <Button>{t("signOut")}</Button>
This is enforced by review, not tooling — there is no eslint rule today that flags hardcoded strings. If you spot one in a PR, ask for it to go through the helper.
Add a string
// messages/en.json
{
"patients": {
"list": {
"empty": "No patients yet."
}
}
}// messages/ro.json — same key, translated value
{
"patients": {
"list": {
"empty": "Niciun pacient deocamdată."
}
}
}const t = useTranslations("patients.list")
{patients.length === 0 && <p>{t("empty")}</p>}Add a locale
- Register the new code in
packages/api-client/src/proxy.tsSUPPORTED_LOCALES. - Ship
messages/{locale}.jsonin clinic and portal pluspackages/i18n/messages/{locale}.json. The build will fail at the first missing file. (Console is English-only — see Scope.) - Update this document's locale list.
The platform stores organizations.language_code as TEXT NOT NULL DEFAULT 'en', with no constraint enforcing the supported set at the DB level. The frontend normalization (normalizeLocale) is what bounds the actual rendered locale — values not in SUPPORTED_LOCALES silently fall back to 'en'.
Romanian translation conventions
Use the project's translate-ro skill when adding Romanian strings. It enforces:
- Proper diacritics (ă, â, î, ș, ț — never cedilla forms ş, ţ)
- Cacophony avoidance (no adjacent words starting with "ca")
- Established Romanian terminology over literal translations
- Cross-app consistency for shared concepts (Sign out → Deconectare everywhere)
Currently translated locales
en— English (source of truth)ro— Romanian (full coverage of shell strings)
Coverage
Shell + landing + dashboards are fully translated:
- Layouts and auth-gate fallbacks (sign-in redirects, role-gate "access denied" pages)
- Sidebars and headers
- User menu and sign-out controls
- Org switcher (clinic only)
- Dashboard placeholders
Console (apps/console/) is wholly hardcoded English by design — see Scope. There is no useTranslations() helper to add new strings to. The convention for clinic and portal is unchanged: "no new hardcoded strings."
Where the cookie lives
| Cookie | Set by | httpOnly | Read by |
|---|---|---|---|
org-id | proxy.ts | true | Server components via cookies() |
org-slug | proxy.ts | true | Server components via cookies() |
org-name | proxy.ts | true | Server components via cookies() |
language | proxy.ts | false | i18n/request.ts; future client |
The language cookie is non-httpOnly so client-side helpers (e.g., a future date formatter that needs the locale without round-tripping to the server) can read it directly. Today only the server-side i18n/request.ts reads it; the relaxed flag is forward-compatible.