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.json first, 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
FailureWhy it matters
Key missing in one localenext-intl throws on that page
Extra key nowhere elsedead string, or a typo in the path
Empty valuerenders as a blank on screen
Array translated halfwayfour pricing bullets in one language, five in another
ICU placeholder changedthrows 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 locales in src/i18n/routing.ts.
  • -- Add its OG_LOCALES and LOCALE_LABELS entries.
  • -- Create src/messages/&lt;code&gt;.json with 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 (ptpt_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.