Skip to content

Internationalization (i18n)

Audience: Developers adding or editing user-facing copy.

Scope: How next-intl is wired in the LMS, locale parity rules, message-key conventions, and the relationship between LMS chrome and CMS-authored content.

The LMS ships six locales: en, es, fr, de, pt, zh. The default locale is en. Locale negotiation runs in src/proxy.ts (Next.js 16 — not middleware.ts).

Library

Server vs Client

Surface Helper Notes
Server components getTranslations() / getLocale() Async; resolves the active locale per request.
Server actions getTranslations({ locale }) Pass an explicit locale when sending email, etc.
Client components useTranslations() / useLocale() Inside NextIntlClientProvider.
Email templates getTranslations({ locale: user.locale }) Use the recipient's users.locale.

Always use the helper above; never construct locale-specific strings inline.

Key Conventions

  • One namespace per top-level surface, e.g. auth.signIn.title, dashboard.empty.message, admin.users.deactivate.confirm.
  • Keys are dot-separated, lowercase, kebab-cased segments allowed (reset-password.success).
  • Reuse keys across locales — every key present in en.json MUST exist in all five other locale files. CI enforces this via the monorepo script scripts/check-i18n.mjs (npm run check:i18n, also run as part of root npm run check).
  • Enum values from the database (roles, statuses) are mapped to translation keys via helpers in src/lib/i18n/keys.ts. Never render raw enum strings to the UI.

Coverage Rules

  • All visible text — labels, placeholders, button copy, headings, empty states, error toasts, validation messages, ARIA labels, page titles — must be translation-keyed.
  • The same applies to aria-label, aria-describedby, and placeholder attributes used as visual hints.
  • Marketing copy not sourced from the CMS (CTAs, footer links, 404 / 500 fallbacks) is translation-keyed.
  • CMS-authored body content (Portable Text, widget payloads) is rendered as authored. Locale support for CMS content is the CMS's responsibility and is out of scope for the LMS.

Locale Switching

  • Anonymous users: LocaleSwitcher (header dropdown) navigates to /{locale}/... and sets the NEXT_LOCALE cookie. The URL and cookie drive the rendered UI.
  • Signed-in users: users.locale (profile field on the account page) is the source of truth for UI language. src/proxy.ts reads token.locale from the Auth.js session and redirects any request whose URL locale differs from the profile locale (respecting localePrefix: "as-needed").
  • Signed-in switcher: When a session is present, LocaleSwitcher persists the chosen locale via setLocalePreferenceAction (updates users.locale, refreshes the JWT with unstable_update, then navigates). The account profile form uses the same persistence path and navigates immediately after a locale change.
  • Emails: Email subjects and bodies always use the recipient's users.locale, independent of the active page locale.

CI Checks

npm run check:i18n (from the repo root; also part of npm run check) runs scripts/check-i18n.mjs. Canonical behavior and flags are documented in Root Scripts Reference.

Vitest tests/unit/i18n/messages.parity.test.ts and tests/unit/i18n/messages.leftovers.test.ts mirror parity and leftover rules.

The Playwright i18n regression suite (Phase 11) visits each top-level route on es and asserts no English-locale-only string from en.json appears in the rendered HTML.

Adding a Locale

Promoting a seventh locale is a follow-up tracked in roadmap.md. When the time comes:

  1. Add messages/<locale>.json mirroring en.json keys exactly.
  2. Update the locale list in src/i18n/.
  3. Add the locale to LocaleSwitcher.
  4. Add the locale to the i18n smoke and regression Playwright projects.
  5. Update reference/tech-stack.md.