Internationalization
Overview
The app is fully internationalized with next-intl. It ships with en,
es, fr, de, nl and pt enabled; every page lives under
src/app/[locale]/ and is rendered in the reader's language. Routing and
locales are defined in src/i18n/routing.ts — that list is yours to trim, so
nothing in the app should assume a particular locale is present.
Messages
UI strings live in one catalog per locale — messages/<locale>.json — grouped
by namespace. A key must exist in every catalog: messages-parity.test.ts
enforces the bijection between the files and routing.locales, so a key added
to one language and forgotten in another fails the suite. Never hardcode
user-visible text:
// Client component
const t = useTranslations("marketing")
return <h1>{t("home.headline")}</h1>
// Server component
const t = await getTranslations("marketing")
Add every new key to both en.json and es.json in the same change. A test
(messages-parity.test.ts) fails the build if the two files drift out of sync,
so missing translations can't slip through.
Navigation
Always use the navigation helpers from @/i18n/navigation — Link, redirect,
useRouter, usePathname — never the raw Next.js equivalents. They keep the
active locale in the URL automatically:
import { Link } from "@/i18n/navigation"
<Link href="/pricing">…</Link> // resolves to /en/pricing or /es/pricing
Formatting
Format dates, numbers, and currencies with next-intl's formatter, not
toLocaleDateString or Intl directly:
const format = useFormatter()
format.dateTime(date, { dateStyle: "long" })
The user's language drives everything
A signed-in user's saved locale preference drives the entire UI — panel and
public pages alike — and their emails. The preference wins over the URL locale;
the proxy (src/proxy.ts) redirects to it. A public language switcher persists
the choice before navigating.
Localized errors and emails
- tRPC errors carry translation keys, not English strings (see The tRPC API), so the UI translates them.
- Email templates accept a
localeprop and render in the recipient's language (see Email).
RTL-readiness
Use CSS logical properties (ps/pe, ms/me, border-s) rather than
directional ones (pl/pr) in your styling layer, so a right-to-left locale
lays out correctly if you add one.
Adding a locale
Add the locale to src/i18n/routing.ts, create its messages/<locale>.json
(keys matching the others) and its messages/product/<locale>.json, and — for
these docs — a src/content/docs/<locale>/ folder. Missing doc pages fall back
to the default locale automatically.
Removing one
The shipped set is a starting point, not a commitment. Delete the locale from
routing.locales and delete its messages/<locale>.json and
messages/product/<locale>.json — the parity test requires the two to match,
and it is what catches a half-finished removal. Unused catalogs never reach the
client either way (messages load per request locale), so trimming is about
which languages you offer, not bundle size.
Tests must survive this, which is why none of them name a locale: they import
SUPPORTED_LOCALE from @/test/locales, or mock @/i18n/routing when the
test is about the locale list itself. See docs/adopting.md for the full
checklist.
The build has to survive it too, which is the harder half: a
import es from "../../messages/es.json" fails to resolve once that file is
gone, and no test sees it because tests never build. Catalogs are therefore
loaded by expression — messages/${locale}.json — and
src/i18n/messages-imports.test.ts fails on any literal path. Same rule for
types: derive them from routing.locales (Locale, localeSchema,
toSupportedLocale in src/i18n/locales.ts) rather than writing
"en" | "es", which type-checks fine until someone trims the union.