Internationalization
Eight languages covering the signed-in application, not just the landing page.
Most starters translate the marketing pages and stop at the login wall. Here the whole product is localized: dashboard, settings, billing, admin, organizations, API keys.
# How it is laid out
src/messages/ en.json fr.json de.json es.json it.json nl.json pl.json pt.json src/i18n/routing.ts locales, defaultLocale, hreflang tables src/i18n/request.ts loads the right file per request src/i18n/navigation.ts locale-aware Link, redirect, usePathname
Inside each file the app namespace covers the signed-in screens; everything else is marketing. en is the default and the reference.
# Using a string
// server component
import { getTranslations, getLocale } from "next-intl/server";
const t = await getTranslations("app.billing");
<h1>{t("title")}</h1>
// client component
import { useTranslations } from "next-intl";
const t = useTranslations("app.billing");
Navigate with the locale-aware Link from @/i18n/navigation, never next/link — the plain one drops the locale prefix.
# Adding a string
- -- Add the key to
en.jsonfirst, in the right namespace. - -- Add the same path to the other seven, translated — not copied.
- -- Run
npm test.
The build fails on drift, on purpose
A missing translation is invisible in review and obvious to the customer. The parity suite is what turns “8 languages” from a marketing number into a property of the code.
# Plurals and rich text
Counting something means an ICU plural, not string concatenation:
{
"userCount": "{count, plural, =0 {no users} one {# user} other {# users}}"
}
Polish takes four forms — one, few, many, other. English and French take two. Use the language's real rule rather than pasting the English shape everywhere.
For emphasis inside a sentence, put the tag in the message and the renderer at the call site:
// en.json
"currentPlanDescription": "You are currently on the <b>{plan}</b> plan."
// page.tsx
t.rich("currentPlanDescription", {
plan: planConfig.name,
b: (chunks) => <strong>{chunks}</strong>,
})
# Dates, numbers, money
const locale = await getLocale(); formatDate(user.createdAt, locale); formatPrice(mrr, CURRENCY, locale); formatNumber(limit, locale);
Omitting the locale is a bug, not a default
These helpers fall back to en-US. On a Polish page that renders an American date silently — nothing throws, nothing is logged, and only a Polish visitor notices.
# The checks
Two, and they agree because they read the same analysis:
npm test # src/__tests__/i18n/messages.test.ts npm run audit # the Locales section
| Failure | Why it matters |
|---|---|
| Key missing in one locale | next-intl throws on that page |
| Extra key nowhere else | dead string, or a typo in the path |
| Empty value | renders as a blank on screen |
| Array translated halfway | four pricing bullets in one language, five in another |
| ICU placeholder changed | throws at render, not at build |
The last row is why the suite exists. {count} seats in English and {n} places in French typechecks perfectly and crashes for French visitors.
# Adding a language
- -- Add the code to
localesinsrc/i18n/routing.ts. - -- Add its
OG_LOCALESandLOCALE_LABELSentries. - -- Create
src/messages/<code>.jsonwith every key. - -- Add the code to the locale-prefixed matchers in
src/middleware.ts. - --
npm test && npm run build.
Forgetting the middleware makes every protected page public in that language
The matchers list locales explicitly: "/(fr|de|es|it|nl|pl|pt)/dashboard(.*)". A new code that is not in that list is not protected, and nothing will tell you.
# Routing and SEO
- --
localePrefix: "as-needed"— English is unprefixed, the rest are prefixed. - -- hreflang alternates and a per-locale sitemap are generated from the same routing config.
- -- Open Graph uses the full locale (
pt→pt_BR); hreflang uses the bare language code. - -- Only publish a URL per language when the body is genuinely translated. A duplicate page in eight languages is eight thin pages, not eight markets.