Skip to content
Chapter 85Lesson 1

Project overview

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 end state in `en-US` — none of it is built in this lesson; the surface you clone renders English at every URL.

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:

  • Resolving the locale exactly once, in middleware, and reading only the resolved value everywhere downstream — never reaching for Accept-Language or navigator.language again outside the sign-up form.
  • Routing every UI string through 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.
  • Writing ICU 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.
  • Treating currency as data that lives on the invoice and formatting it for whoever is viewing, so the same EUR amount renders one way to a French user and another to an American one.
  • Emitting bidirectional hreflang with x-default, a locale-specific canonical, and a per-locale sitemap from generateMetadata and app/sitemap.ts.
  • Holding the structural discipline that keeps all of this maintainable: grep-able translation keys, formatters confined to one seam, <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.

  • Middleware (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.
  • Request config (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.
  • Session and store. 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.
  • The [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.
  • Render seams. Strings come from 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.
  • SEO surface. 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.

  • Directorysrc/
    • global.ts augments next-intl with real Locale / Messages / Formats types
    • proxy.ts locale negotiation middleware; matcher excludes /inspector
    • Directoryi18n/
      • routing.ts defineRouting: locales, default, localePrefix: 'as-needed'
      • navigation.ts typed Link / redirect / usePathname / useRouter
      • request.ts TODO(L2) — real per-locale dynamic import (start hard-codes en-US)
      • formats.ts TODO(L2) — dateTime + number (compact); TODO(L3) — number.currency
    • Directorylib/
      • temporal.ts Temporal seam + instantFromString / plainDateFromString
      • user-time.ts getCurrentUserTimeZone / getCurrentUserLocale (read off session)
      • authed-action.ts authedAction wrapper: session → RBAC → parse → fn
      • result.ts Result<T> + ok / err / conflict
      • i18n/supported.ts SUPPORTED_LOCALES = ['en-US','en-GB','fr-FR'] as const
      • Directoryinvoices/ carry-in: search-params, scoped-query, queries, actions
      • Directoryseo/
        • alternates.ts generateAlternates(pathname, locale) + APP_URL
        • og-locale.ts bcp47ToOgLocale: 'fr-FR''fr_FR'
    • Directoryserver/
      • types.ts Invoice, UserProfile, Role, roleAtLeast
      • session.ts cookie-driven getSession (acting-identity cookie)
      • store.ts in-memory “Postgres”: 4 users, 30 invoices per org, DST fixtures
    • Directorymessages/
      • en-US.json the provided source catalog
      • en-GB.json TODO(L2) — ~15-key diff from en-US (spellings, date order)
      • fr-FR.json TODO(L2) — full French translation with the ICU many branch
    • Directoryapp/
      • layout.tsx bare root (each segment owns its own <html>/<body>)
      • robots.ts allow all, sitemap URL
      • sitemap.ts per-locale sitemap with xhtml:link alternates
      • Directory[locale]/
        • layout.tsx TODO(L2) — <html lang={locale}> + scoped provider
        • Directory(marketing)/
          • layout.tsx header nav + LocaleSwitcher
          • opengraph-image.tsx per-locale OG image
          • page.tsx TODO(L4) — generateMetadata
          • pricing/page.tsx TODO(L4) — generateMetadata
          • features/page.tsx TODO(L4) — generateMetadata
        • Directory(app)/
          • layout.tsx generateMetadata (robots noindex), header + LocaleSwitcher
          • Directoryinvoices/
            • page.tsx TODO(L2) — t() + counter; TODO(L3) — tz + due delta
            • table.tsx TODO(L2) — t() labels; TODO(L3) — formatters
            • locale-switcher.tsx client; calls setLocaleAction + router.replace
            • actions.ts TODO(L2) — setLocaleAction body
            • toolbar / view-tabs / pagination / chips / [id]/edit / loading carry-in
      • Directoryinspector/ provided, fully working: DST proof, currency grid, plural probe, hreflang panel, sitemap preview, version drift, audit tail
    • Directorycomponents/ui/ shadcn/ui primitives (verbatim)

A 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.

  1. Get the starter codebase from the project repository, under Chapter 085/start/.

  2. Install dependencies:

    Terminal window
    pnpm install
  3. Start the dev server:

    Terminal window
    pnpm dev

On success you’ll see the dev server come up with no provisioning step in between:

pnpm dev
Next.js 16.2.7 (Turbopack)
- Local: http://localhost:3000
Starting...
Ready in 1.4s

Now 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.