Lesson 2 — Wire next-intl and ship three catalogs
Wire the per-locale dynamic import, finish the locale layout and the setLocaleAction, fill the two catalogs, and route every UI string through t() with a CLDR-correct pluralized counter.
Most teams reach for internationalization the way they reach for a fire extinguisher: only once something is already burning. A sales lead in Lyon wants the product in French, so an engineer spends three to six weeks tearing hard-coded strings out of JSX, threading a locale through every component, and discovering that half the dates were formatted with the server’s timezone all along. The work is miserable because the codebase was never shaped for it.
This project teaches the opposite reflex. You take the invoices surface you built back in the production-invoices-list project — the one with URL-state filters, soft delete, and version-based concurrency — and lift it into a tri-locale, timezone-aware surface where internationalization is a structural property of the code from the first commit. Three locales ship from day one: en-US, en-GB, and fr-FR. Every visible string flows through a translation function t(), every number through a formatter, every date through that same formatter with the viewer’s profile timezone, and the marketing pages emit hreflang tags, per-locale sitemap entries, and locale-aware Open Graph metadata. The point isn’t the three languages — it’s that once the seams exist, adding a fourth is one pull request, not a rewrite.
The data underneath is deliberately boring so the i18n work can be the whole show. There is no Postgres, no Better Auth, no Docker. The substrate is an in-memory store standing in for the database, read through a cookie-driven dev session, so the surface boots under pnpm dev with nothing to provision. And to be honest about where you start: the code you clone routes every locale but does not yet localize — /fr-FR/invoices resolves, but it renders English. Closing that gap is the work of the three build lessons.
The payoff to keep in view: when internationalization is wired as structure rather than bolted on, every surface you add later inherits the [locale] segment, the translation catalog, and the formatter reflex for free. A fourth locale becomes a single pull request — a new catalog file and a one-line change to a constant — instead of a multi-week archaeology dig through the codebase.
The skills you’ll build toward that:
Accept-Language or navigator.language again outside the sign-up form.t() (and t.rich for strings with embedded markup), every number through the formatter, and every date through the formatter with the user’s profile timezone, read from the session at the call site.plural catalogs that respect each language’s CLDR plural categories — including French’s many branch for large numbers and the =0 exact-match override — instead of faking pluralization with a ternary.hreflang with x-default, a locale-specific canonical, and a per-locale sitemap from generateMetadata and app/sitemap.ts.<html lang> driven from the resolved locale, and static rendering preserved with setRequestLocale.A request flows through a short, linear pipeline. Each stage owns one concern, and the seams between them are exactly what makes a fourth locale cheap.
src/proxy.ts). createMiddleware(routing) resolves the locale through a five-step negotiation chain — URL prefix, then the user’s profile, then the NEXT_LOCALE cookie, then the best Accept-Language match, then the default — and rewrites the URL once so the [locale] segment can match. Its matcher deliberately excludes inspector.src/i18n/request.ts). getRequestConfig settles the locale, the messages (dynamically imported per locale), and the shared formats once per request — and nothing tied to the request, no session, no timezone, no now. That restraint is what lets every locale’s static prerender stay green.src/server/session.ts resolves the acting-identity cookie to one of four seeded identities; src/server/store.ts is the in-memory “Postgres”; src/lib/user-time.ts reads the locale and timeZone off the session.[locale]/ segment. setRequestLocale keeps the marketing pages static; <html lang> matches the URL prefix; a NextIntlClientProvider ships only the scoped slice of the catalog the client actually needs.useTranslations / getTranslations; dates and currency come from useFormatter in the client table; src/i18n/formats.ts holds the presets and src/lib/temporal.ts holds the date codecs. The page reads the timezone on the server and threads it down into the table.src/lib/seo/alternates.ts feeds every marketing page’s generateMetadata; src/app/sitemap.ts emits the per-locale alternates.The starter and the solution share one identical file tree — no files are added across the project. Everything load-bearing for routing, navigation, the SEO seams, the session and store, and the full inspector is already provided and working. Your work is the in-file deltas marked TODO(L2) / TODO(L3) / TODO(L4), where the digit names the lesson that fills it. Those are the highlighted files below; the rest is either carry-in from the earlier project or a finished seam you read but never edit.
Locale / Messages / Formats types/inspectordefineRouting: locales, default, localePrefix: 'as-needed'Link / redirect / usePathname / useRouterdateTime + number (compact); TODO(L3) — number.currencyinstantFromString / plainDateFromStringgetCurrentUserTimeZone / getCurrentUserLocale (read off session)authedAction wrapper: session → RBAC → parse → fnResult<T> + ok / err / conflictSUPPORTED_LOCALES = ['en-US','en-GB','fr-FR'] as constgenerateAlternates(pathname, locale) + APP_URLbcp47ToOgLocale: 'fr-FR' → 'fr_FR'Invoice, UserProfile, Role, roleAtLeastgetSession (acting-identity cookie)many branch<html>/<body>)xhtml:link alternates<html lang={locale}> + scoped providergenerateMetadatagenerateMetadatagenerateMetadatagenerateMetadata (robots noindex), header + LocaleSwitchert() + counter; TODO(L3) — tz + due deltat() labels; TODO(L3) — formatterssetLocaleAction + router.replacesetLocaleAction bodyA few of the provided files are worth a word now; each one gets its full treatment in the lesson that first leans on it.
messages/en-US.json is the source contract for every catalog. Each key follows the feature.surface.role shape, placeholders are named, strings with embedded markup are tagged for t.rich, and the invoices counter carries the ICU plural branches =0 / one / other. You don’t translate English here — it’s provided in full. The exercise is the shape: in Lesson 2 you mirror it into the two empty catalogs, where French forces you to add the many branch English doesn’t have.
The store (src/server/store.ts) seeds four users — (en-US, America/New_York), (en-GB, Europe/London), (fr-FR, Europe/Paris), and the deliberately mismatched (fr-FR, Pacific/Auckland) — plus thirty invoices per organization in a USD/GBP/EUR mix, two fixtures whose due dates straddle European daylight saving, one archived row, and one soft-deleted row. Currency is plain text on each invoice: a row issued in EUR stays EUR no matter who looks at it, and the viewer’s locale decides only how the symbol and separators are drawn.
The seams you’ll read but not write are src/lib/user-time.ts (getCurrentUserTimeZone and getCurrentUserLocale, both cached, both reading off the session) and src/lib/i18n/supported.ts, whose single line SUPPORTED_LOCALES = ['en-US','en-GB','fr-FR'] as const is the one source of truth that the as const narrows into the Locale union the whole project types against. The locale switcher in the header is a finished client component — it renders each language in its own name and calls setLocaleAction, whose body you write in Lesson 2. And next.config.ts already wraps the app with createNextIntlPlugin('./src/i18n/request.ts') and carries cacheComponents: true forward from the earlier project, which is the wiring that hooks your request-config module into every render.
Three lessons, each closing on a state you can confirm in the browser or the inspector.
Lesson 2 — Wire next-intl and ship three catalogs
Wire the per-locale dynamic import, finish the locale layout and the setLocaleAction, fill the two catalogs, and route every UI string through t() with a CLDR-correct pluralized counter.
Lesson 3 — Format dates in profile tz and currency from data
Flow every date through the formatter with the viewer’s profile timezone and every amount through it with the invoice’s stored currency, plus a Temporal-driven relative-due column.
Lesson 4 — Emit hreflang, sitemap alternates, and per-locale OG
Add the public SEO surface: bidirectional hreflang with x-default, a locale-specific canonical, and per-locale OG images on the marketing pages.
There is no database, no Docker, and no .env to fill. The data lives in the in-memory store and the dev session is a cookie, so the app boots with zero provisioning. The one URL the SEO surface needs — APP_URL for canonical and hreflang links — is a plain constant (https://app.example.com) in src/lib/seo/alternates.ts, not an environment variable.
Get the starter codebase from the project repository, under Chapter 085/start/.
Install dependencies:
pnpm installStart the dev server:
pnpm devOn success you’ll see the dev server come up with no provisioning step in between:
▲ Next.js 16.2.7 (Turbopack) - Local: http://localhost:3000
✓ Starting... ✓ Ready in 1.4sNow walk the before-state, so you know exactly what the build lessons change. Visit /invoices and then /fr-FR/invoices: both route, both render the invoices surface from the earlier project — and both render in English. That’s not a bug to fix in a panic; it’s the honest starting line. The starter’s request.ts resolves every locale to the en-US catalog and the locale layout still hard-codes <html lang="en-US">, so the prefix routes without yet changing a single string. Open /inspector and you’ll find the DST proof, the currency grid, and the pluralization probe already alive, while the hreflang and sitemap panels sit empty — they fill in once you reach Lesson 4.
A word on scope, so expectations are set before you start. Only the three locales ship — no right-to-left languages, no de-DE, es-ES, or pt-PT. (The “add a fourth in one pull request” claim gets rehearsed for real much later in the course, when you review a pull request that adds a locale — not built here.) The catalogs live in the repository as plain JSON; the format is exactly what a translation management system like Crowdin or Lokalise round-trips, but wiring one up is out of scope. Per-locale Open Graph images land on the marketing pages only — no structured-data localization, no A/B locale variants, no domain-based routing. The in-memory store stands in for a real database, so there’s no Postgres, Drizzle, or auth here. And the only Temporal arithmetic you’ll write is the single relative-due day delta in Lesson 3; the rest of Temporal’s surface was covered earlier in the course.
By the end of this lesson the starter runs locally and renders the carry-in list at every locale’s URL — in English everywhere. That before-state is your baseline. The next lesson makes the language follow the URL.