Skip to content
Chapter 85Lesson 1

Project: tri-locale invoices list

A tri-locale, timezone-aware invoices list wired with next-intl, and how to run the starter.

Most teams add internationalization only once it’s urgent. A sales lead in Lyon wants the product in French, and an engineer spends weeks pulling hard-coded strings out of JSX, threading a locale through every component, and discovering that half the dates were formatted in the server’s timezone all along. The retrofit hurts because the codebase was never shaped for it.

This project builds the opposite reflex. You take the invoices surface from the production-invoices-list project — URL-state filters, soft delete, version-based concurrency — and make internationalization structural from the first commit, with three locales shipping from day one: en-US, en-GB, and fr-FR. Every string flows through a translation function, every number and date through a formatter tied to the viewer’s 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, a fourth is one pull request, not a rewrite.

The data is deliberately boring so the i18n work can be the whole show: no Postgres, no Better Auth, no Docker, just an in-memory store behind a cookie-driven dev session, so the surface boots under pnpm dev with nothing to provision. The code you clone routes every locale but doesn’t yet localize it: /fr-FR/invoices resolves, but renders English. Closing that gap is the work of the three build lessons.

The end state in `en-US`, the language the cloned surface renders at every URL.

The three build lessons develop these skills:

  • Resolve the locale once in middleware, then read that resolved value everywhere downstream.
  • Route every string through t(), every number through a formatter, and every date through that formatter with the viewer’s profile timezone.
  • Write ICU plural catalogs that follow each language’s CLDR categories, including French’s many branch and the =0 override.
  • Treat currency as data on the invoice, so the same EUR amount formats one way for a French viewer and another for an American one.
  • Emit bidirectional hreflang with x-default, a locale-specific canonical, and a per-locale sitemap.
  • Keep it maintainable: grep-able keys, formatters confined to one seam, <html lang> driven from the resolved locale, and static rendering preserved with setRequestLocale.

A request flows through a linear pipeline, each stage owning one concern.

  • Middleware (src/proxy.ts). createMiddleware(routing) resolves the locale through a five-step chain — URL prefix, user profile, NEXT_LOCALE cookie, best Accept-Language match, then the default — and rewrites the URL so the [locale] segment matches. Its matcher excludes inspector.
  • Request config (src/i18n/request.ts). getRequestConfig settles locale, messages (imported per locale), and the shared formats once per request — no session, timezone, or now, which keeps every locale’s prerender static.
  • 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 store; src/lib/user-time.ts reads locale and timeZone off the session.
  • The [locale]/ segment. setRequestLocale keeps the marketing pages static; <html lang> matches the URL prefix; NextIntlClientProvider ships only the scoped catalog slice the client needs.
  • Render seams. Strings come from useTranslations / getTranslations; dates and currency from useFormatter in the client table. src/i18n/formats.ts holds the presets, src/lib/temporal.ts the date codecs. The page reads the timezone on the server and threads it into the table.
  • SEO surface. src/lib/seo/alternates.ts feeds each marketing page’s generateMetadata; src/app/sitemap.ts emits the per-locale alternates.

Starter and solution share one file tree; no files are added across the project. Your work is the in-file deltas marked TODO(L2) / TODO(L3) / TODO(L4), where the digit names the lesson that fills it — the highlighted files below. Everything else is provided and working: routing, navigation, the SEO seams, the session, the store, and the inspector are read but never edited.

  • 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 provided files are worth knowing now.

messages/en-US.json is the source contract for every catalog. Keys follow 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 reproduce this shape, not the English, when you mirror it into the two empty catalogs in Lesson 2 — where French adds the many branch.

src/server/store.ts seeds four users — (en-US, America/New_York), (en-GB, Europe/London), (fr-FR, Europe/Paris), and a deliberately mismatched (fr-FR, Pacific/Auckland) — plus thirty invoices per org in a USD/GBP/EUR mix, two fixtures straddling European DST, one archived row, and one soft-deleted row. Currency is plain data: an EUR row stays EUR for every viewer, and the viewer’s locale decides only how the symbol and separators are drawn. Two seams you read but never write: src/lib/user-time.ts, whose getCurrentUserTimeZone / getCurrentUserLocale read off the session, and src/lib/i18n/supported.ts, whose SUPPORTED_LOCALES ... as const narrows into the project’s Locale union. The header locale switcher is a finished client component calling setLocaleAction, whose body you write in Lesson 2; next.config.ts already wraps the app with createNextIntlPlugin('./src/i18n/request.ts').

Lesson 2 — Wire next-intl and ship three catalogs

Wire the per-locale import, finish the locale layout and setLocaleAction, fill the two catalogs, and route every string through t() with a CLDR-correct pluralized counter.

Lesson 3 — Format dates in profile tz and currency from data

Format every date in the viewer’s profile timezone and every amount in its invoice’s stored currency, with a Temporal-driven relative-due column.

Lesson 4 — Emit hreflang, sitemap alternates, and per-locale OG

Add the marketing SEO surface: bidirectional hreflang with x-default, a locale-specific canonical, and per-locale OG images.

No database, no Docker, no .env: the data lives in an in-memory store, the dev session is a cookie, and the one URL the SEO surface needs is a constant (APP_URL, https://app.example.com) in src/lib/seo/alternates.ts.

  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

The dev server comes up with nothing to provision:

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

Visit /invoices and /fr-FR/invoices: both route to the invoices surface, and both render in English, because the starter’s request.ts resolves every locale to the en-US catalog. Open /inspector to find the DST proof, currency grid, and pluralization probe already alive; the hreflang and sitemap panels stay empty until Lesson 4. This is your baseline: the next lesson makes the language follow the URL.

Scope is the three locales only, no right-to-left languages. The catalogs ship as plain JSON in the format a translation management system like Crowdin or Lokalise round-trips, but wiring one up is out of scope.