Skip to main content
Documentation

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.

Always use the navigation helpers from @/i18n/navigationLink, 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 locale prop 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.