Skip to content

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.

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 through PATCH /v1/me. Resolution order in proxy.ts is user preference → org language_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:

  1. next.config.mjs is wrapped with createNextIntlPlugin('./i18n/request.ts').
  2. i18n/request.ts exports a getRequestConfig that returns {locale, messages} per request.
  3. app/layout.tsx wraps 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 + mergeMessages

Clinic 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:

  1. Pick a key in messages/en.json under the appropriate namespace (auth, nav, errors, dashboard, etc.).
  2. Add the same key with the Romanian translation in messages/ro.json.
  3. 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

json
// messages/en.json
{
  "patients": {
    "list": {
      "empty": "No patients yet."
    }
  }
}
json
// messages/ro.json — same key, translated value
{
  "patients": {
    "list": {
      "empty": "Niciun pacient deocamdată."
    }
  }
}
tsx
const t = useTranslations("patients.list")
{patients.length === 0 && <p>{t("empty")}</p>}

Add a locale

  1. Register the new code in packages/api-client/src/proxy.ts SUPPORTED_LOCALES.
  2. Ship messages/{locale}.json in clinic and portal plus packages/i18n/messages/{locale}.json. The build will fail at the first missing file. (Console is English-only — see Scope.)
  3. 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."

CookieSet byhttpOnlyRead by
org-idproxy.tstrueServer components via cookies()
org-slugproxy.tstrueServer components via cookies()
org-nameproxy.tstrueServer components via cookies()
languageproxy.tsfalsei18n/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.