Wire next-intl and ship three catalogs
The starter routes but doesn’t translate.
Visit /invoices and you get English; visit /fr-FR/invoices and you still get English — the [locale] segment matches, but every string comes out the same.
By the end of this lesson, a locale-prefixed URL renders the whole invoices surface in its own language, with the correct plural form for that language.
Working looks like this.
The unprefixed / loads the default-locale marketing page; /fr-FR/ loads it in French.
/invoices, /fr-FR/invoices, and /en-GB/invoices each render in their own language — French column headers, French status labels, the British spelling of “localised”.
The header’s switcher rewrites the URL, and the choice sticks across navigation.
The “N invoices” counter reads naturally in each, and its French branch for large numbers is what trips people up first.
One thing stays unfinished on purpose: the amount still renders as EUR 1234.56 and dates come out however toLocaleDateString returns them.
Currency and date formatting are the next lesson’s job.
This lesson is the spine: locale resolved once, every string read from a per-locale catalog, the right plural category per language.
Your mission
Section titled “Your mission”You’re installing the canonical 2026 internationalization spine onto the carry-in invoices list, and its shape is the lesson.
Locale is resolved exactly once, upstream, in middleware; downstream code only reads the resolved value.
Every visible string flows out of a per-locale JSON catalog through a translation function, so en-US.json is the source contract and the other two catalogs translate it.
Three traps are most of the work.
The first is silent and expensive: every page and layout under [locale]/ must call setRequestLocale before any other next-intl call.
Skip it and the route quietly flips from static to dynamic — no error, no warning, just a performance regression you can’t see without monitoring.
The second is type safety: the request can carry any string in the locale position, so hasLocale(routing.locales, requested) both narrows the type and lets the layout bail with notFound() instead of rendering a broken page.
The third is payload bloat: scope the client provider to only the namespaces client components read, and have the request config dynamic-import the active locale’s JSON, so production ships one catalog rather than all three.
The switch action writes two signals: the user’s profile in the store and the NEXT_LOCALE cookie with sameSite: 'lax'.
Both must agree so the choice survives navigation and the negotiation chain reads it back consistently.
And the load-bearing trap of the lesson: CLDR gives French a many category for large numbers, so an ICU plural message shipping only one and other silently mistranslates 1000000.
The counter uses =0 / one / many / other, never a ternary.
Out of scope: date and currency formatting (next lesson), translation-management-system wiring (the catalogs check in as JSON a TMS can round-trip, but you wire none here), and SEO metadata.
Three seams are provided complete — read them, don’t rewrite them: routing.ts (the 'as-needed' prefix strategy), navigation.ts (the typed Link and redirect), and proxy.ts, Next.js 16’s rename of middleware.ts, which holds the whole negotiation chain .
/fr-FR/invoices, English at /invoices.No invoices / 1 invoice / 5 invoices; fr-FR shows Aucune facture / 1 facture / 1 000 000 de factures (the many branch).<html lang> matches the URL prefix on every path — /fr-FR/… yields fr-FR, an unprefixed path yields en-US, /en-GB/… yields en-GB.locale and sets the NEXT_LOCALE cookie./ loads the default-locale marketing page unprefixed, /fr-FR/ loads it in French, and /invoices, /fr-FR/invoices, /en-GB/invoices all route and render in their own language.Accept-Language: fr-FR is redirected from / to /fr-FR/; an unsupported pt-BR loads / unprefixed and falls back to en-US.next build — a missing setRequestLocale flips them dynamic, and there’s no panel for this, so verify it by hand./invoices?status=paid&cursor=abc123 becomes /fr-FR/invoices?status=paid&cursor=abc123.Coding time
Section titled “Coding time”Implement against the brief above and the lesson tests. Read the walkthrough below only after you have a real attempt working, or you are stuck.
Reference solution and walkthrough
The work spans eight files. Build them in reading order: the request config and format presets first, since everything reads from them, then the layout and switch action, then the page and table, and finally the two translation catalogs.
src/i18n/request.ts
Section titled “src/i18n/request.ts”next-intl evaluates this seam once per request to pick which catalog to load. The starter hard-codes the en-US catalog for every locale, which is why the prefix has no visible effect; interpolating the locale into the import path fixes it.
import { hasLocale } from 'next-intl';import { getRequestConfig } from 'next-intl/server';import { formats } from '@/i18n/formats';import { routing } from '@/i18n/routing';
export default getRequestConfig(async ({ requestLocale }) => { const requested = await requestLocale; const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
const messages = (await import(`../messages/${locale}.json`)).default;
return { locale, messages, formats };});The dynamic import(`../messages/${locale}.json`) code-splits each catalog into its own chunk, so production ships only the active locale’s JSON instead of all three.
hasLocale(routing.locales, requested) validates the segment before use, so the raw request is never trusted.
What this config omits matters as much.
It returns only { locale, messages, formats }, never a request-tied read like cookies() or new Date(): getRequestConfig runs during the static prerender of every locale, and such a read fails it with the “Uncached data was accessed outside of <Suspense>” error.
The user’s timezone is read at the formatter call site next lesson, not here.
src/i18n/formats.ts
Section titled “src/i18n/formats.ts”The starter exports an empty {}.
These are the shared formatter presets, referenced by name at the call sites so a UI-wide formatting change is a single edit.
import type { Formats } from 'next-intl';
export const formats = { dateTime: { short: { dateStyle: 'medium' }, withTime: { dateStyle: 'medium', timeStyle: 'short' }, }, number: { compact: { notation: 'compact' }, },} as const satisfies Formats;There is no number.currency preset yet; it arrives next lesson, when amounts move onto the formatter.
There is no relativeTime key either, and there cannot be: next-intl’s Formats type has slots only for dateTime, number, list, and displayName, so adding one fails tsc.
app/[locale]/layout.tsx
Section titled “app/[locale]/layout.tsx”The shell is already wired: generateStaticParams, setRequestLocale, the NuqsAdapter, and a small pick helper.
Your job is two lines: drive <html lang> from the resolved param, and scope the client provider.
const LocaleLayout = async ({ children, params }: LocaleLayoutProps) => { const { locale } = await params; if (!hasLocale(routing.locales, locale)) { notFound(); } setRequestLocale(locale); const messages = await getMessages();
return ( <html lang="en-US" suppressHydrationWarning> <body className="font-sans antialiased"> <Providers> <NuqsAdapter> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> <Toaster /> </NuqsAdapter> </Providers> </body> </html> );};The carry-in shell. <html lang> is pinned to en-US, and the full unscoped catalog ships to every client.
const LocaleLayout = async ({ children, params }: LocaleLayoutProps) => { const { locale } = await params; if (!hasLocale(routing.locales, locale)) { notFound(); } // `setRequestLocale` first, before any other next-intl call, so this segment // stays statically renderable. `<html lang>` is driven from the resolved URL // param (never the cookie) to avoid a hydration mismatch. setRequestLocale(locale); const messages = await getMessages();
return ( <html lang={locale} suppressHydrationWarning> <body className="font-sans antialiased"> <Providers> {/* NuqsAdapter is load-bearing: without it every nuqs client hook throws and the toolbar/pagination break. */} <NuqsAdapter> <NextIntlClientProvider messages={pick(messages, ['invoices', 'nav', 'locale-switcher'])} > {children} </NextIntlClientProvider> <Toaster /> </NuqsAdapter> </Providers> </body> </html> );};The two-line delta. <html lang={locale}> reads the resolved URL param, and the provider is pick-scoped to the three namespaces client components actually read.
Two facts make those lines load-bearing.
setRequestLocale(locale) must run before getMessages(): it opts the segment into static rendering, and any next-intl call ahead of it forces the segment dynamic.
And <html lang> reads the URL param, never the cookie, because a cookie read can leave server and client disagreeing on the language at first paint, a hydration mismatch. (suppressHydrationWarning covers only the theme class next-themes injects; it is not a license to mismatch lang.)
The scoped provider is the payload win.
pick(messages, ['invoices', 'nav', 'locale-switcher']) ships only the namespaces client components read; the marketing copy and metadata stay server-only.
Without it, the whole catalog crosses the wire to every client.
app/[locale]/(app)/invoices/actions.ts
Section titled “app/[locale]/(app)/invoices/actions.ts”The starter returns err('internal', 'Not implemented').
Fill the body so the switch writes both signals.
'use server';
import { cookies } from 'next/headers';import { z } from 'zod';import { authedAction } from '@/lib/authed-action';import { SUPPORTED_LOCALES } from '@/lib/i18n/supported';import { ok, type Result } from '@/lib/result';import { setUserLocale } from '@/server/store';
export const setLocaleAction = authedAction( 'member', z.strictObject({ locale: z.enum(SUPPORTED_LOCALES) }), async (input, ctx): Promise<Result<null>> => { setUserLocale(ctx.userId, input.locale); (await cookies()).set('NEXT_LOCALE', input.locale, { path: '/', sameSite: 'lax', }); return ok(null); },);The store write feeds the negotiation chain’s profile step and the cookie write feeds its cookie step; the two must agree, or the URL and the session drift apart.
sameSite: 'lax' is right for a locale cookie: it rides top-level navigations, which is all you need, and unlike 'strict' it survives OAuth callbacks. (A locale preference is GDPR “essential for functionality,” so it needs no consent banner.)
NEXT_LOCALE is the cookie next-intl reads by default.
You don’t write the switcher; locale-switcher.tsx is provided.
It calls this action, then does a typed router.replace to re-prefix the URL onto the new locale, preserving the current path and query.
app/[locale]/(app)/invoices/page.tsx
Section titled “app/[locale]/(app)/invoices/page.tsx”Three deltas on the carry-in page: opt into static rendering, get a translator, and route the three strings through it.
The table still receives only rows, view, and role; the timezone and date plumbing are next lesson’s work.
import { notFound } from 'next/navigation';import { hasLocale } from 'next-intl';import { getTranslations, setRequestLocale } from 'next-intl/server';import type { SearchParams } from 'nuqs/server';import { ActiveFilterChips } from '@/app/[locale]/(app)/invoices/active-filter-chips';import { Pagination } from '@/app/[locale]/(app)/invoices/pagination';import { InvoicesTable } from '@/app/[locale]/(app)/invoices/table';import { Toolbar } from '@/app/[locale]/(app)/invoices/toolbar';import { ViewTabs } from '@/app/[locale]/(app)/invoices/view-tabs';import { routing } from '@/i18n/routing';import { listInvoices, toInvoiceRow } from '@/lib/invoices/queries';import { invoiceListSearchParamsCache } from '@/lib/invoices/search-params';import { getSession } from '@/server/session';
type PageProps = { params: Promise<{ locale: string }>; searchParams: Promise<SearchParams>;};
const InvoicesPage = async ({ params, searchParams }: PageProps) => { const { locale } = await params; if (!hasLocale(routing.locales, locale)) { notFound(); } setRequestLocale(locale); const t = await getTranslations('invoices.list');
const parsed = await invoiceListSearchParamsCache.parse(searchParams); const session = await getSession();
const { rows, nextCursor, hasPrev } = listInvoices({ orgId: session.orgId, role: session.role, ...parsed, });
return ( <div data-testid="invoices-page" className="space-y-4"> <h1 className="text-xl font-semibold">{t('title')}</h1>
<div data-testid="invoices-grid" className="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]" > <div data-testid="invoices-list" className="space-y-4"> <p data-testid="invoice-count" className="text-sm text-muted-foreground" > {t('count', { count: rows.length })} </p> <ViewTabs parsed={parsed} role={session.role} /> <Toolbar parsed={parsed} /> <ActiveFilterChips parsed={parsed} /> <InvoicesTable rows={rows.map(toInvoiceRow)} view={parsed.view} role={session.role} /> <Pagination cursor={parsed.cursor} nextCursor={nextCursor} hasPrev={hasPrev} /> </div>
<aside className="rounded-lg border p-4 text-sm text-muted-foreground"> {t('selectPrompt')} </aside> </div> </div> );};
export default InvoicesPage;Opt the segment into static rendering with setRequestLocale(locale) before any other next-intl call, then grab the server-side translator scoped to invoices.list.
import { notFound } from 'next/navigation';import { hasLocale } from 'next-intl';import { getTranslations, setRequestLocale } from 'next-intl/server';import type { SearchParams } from 'nuqs/server';import { ActiveFilterChips } from '@/app/[locale]/(app)/invoices/active-filter-chips';import { Pagination } from '@/app/[locale]/(app)/invoices/pagination';import { InvoicesTable } from '@/app/[locale]/(app)/invoices/table';import { Toolbar } from '@/app/[locale]/(app)/invoices/toolbar';import { ViewTabs } from '@/app/[locale]/(app)/invoices/view-tabs';import { routing } from '@/i18n/routing';import { listInvoices, toInvoiceRow } from '@/lib/invoices/queries';import { invoiceListSearchParamsCache } from '@/lib/invoices/search-params';import { getSession } from '@/server/session';
type PageProps = { params: Promise<{ locale: string }>; searchParams: Promise<SearchParams>;};
const InvoicesPage = async ({ params, searchParams }: PageProps) => { const { locale } = await params; if (!hasLocale(routing.locales, locale)) { notFound(); } setRequestLocale(locale); const t = await getTranslations('invoices.list');
const parsed = await invoiceListSearchParamsCache.parse(searchParams); const session = await getSession();
const { rows, nextCursor, hasPrev } = listInvoices({ orgId: session.orgId, role: session.role, ...parsed, });
return ( <div data-testid="invoices-page" className="space-y-4"> <h1 className="text-xl font-semibold">{t('title')}</h1>
<div data-testid="invoices-grid" className="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]" > <div data-testid="invoices-list" className="space-y-4"> <p data-testid="invoice-count" className="text-sm text-muted-foreground" > {t('count', { count: rows.length })} </p> <ViewTabs parsed={parsed} role={session.role} /> <Toolbar parsed={parsed} /> <ActiveFilterChips parsed={parsed} /> <InvoicesTable rows={rows.map(toInvoiceRow)} view={parsed.view} role={session.role} /> <Pagination cursor={parsed.cursor} nextCursor={nextCursor} hasPrev={hasPrev} /> </div>
<aside className="rounded-lg border p-4 text-sm text-muted-foreground"> {t('selectPrompt')} </aside> </div> </div> );};
export default InvoicesPage;t('title') resolves invoices.list.title from the active catalog instead of the hard-coded "Invoices".
import { notFound } from 'next/navigation';import { hasLocale } from 'next-intl';import { getTranslations, setRequestLocale } from 'next-intl/server';import type { SearchParams } from 'nuqs/server';import { ActiveFilterChips } from '@/app/[locale]/(app)/invoices/active-filter-chips';import { Pagination } from '@/app/[locale]/(app)/invoices/pagination';import { InvoicesTable } from '@/app/[locale]/(app)/invoices/table';import { Toolbar } from '@/app/[locale]/(app)/invoices/toolbar';import { ViewTabs } from '@/app/[locale]/(app)/invoices/view-tabs';import { routing } from '@/i18n/routing';import { listInvoices, toInvoiceRow } from '@/lib/invoices/queries';import { invoiceListSearchParamsCache } from '@/lib/invoices/search-params';import { getSession } from '@/server/session';
type PageProps = { params: Promise<{ locale: string }>; searchParams: Promise<SearchParams>;};
const InvoicesPage = async ({ params, searchParams }: PageProps) => { const { locale } = await params; if (!hasLocale(routing.locales, locale)) { notFound(); } setRequestLocale(locale); const t = await getTranslations('invoices.list');
const parsed = await invoiceListSearchParamsCache.parse(searchParams); const session = await getSession();
const { rows, nextCursor, hasPrev } = listInvoices({ orgId: session.orgId, role: session.role, ...parsed, });
return ( <div data-testid="invoices-page" className="space-y-4"> <h1 className="text-xl font-semibold">{t('title')}</h1>
<div data-testid="invoices-grid" className="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]" > <div data-testid="invoices-list" className="space-y-4"> <p data-testid="invoice-count" className="text-sm text-muted-foreground" > {t('count', { count: rows.length })} </p> <ViewTabs parsed={parsed} role={session.role} /> <Toolbar parsed={parsed} /> <ActiveFilterChips parsed={parsed} /> <InvoicesTable rows={rows.map(toInvoiceRow)} view={parsed.view} role={session.role} /> <Pagination cursor={parsed.cursor} nextCursor={nextCursor} hasPrev={hasPrev} /> </div>
<aside className="rounded-lg border p-4 text-sm text-muted-foreground"> {t('selectPrompt')} </aside> </div> </div> );};
export default InvoicesPage;The counter passes a number into t('count', …) and the catalog’s ICU plural rule picks the category; t('selectPrompt') localizes the aside. The page never decides which plural branch fires.
The translator is scoped to invoices.list, so every key reads relative to it: t('title') resolves invoices.list.title.
The page hands t('count', …) a number and the catalog’s ICU plural rule decides, per locale, whether it reads as singular, plural, or French’s many.
app/[locale]/(app)/invoices/table.tsx
Section titled “app/[locale]/(app)/invoices/table.tsx”The table is a client component, so it uses the hook form useTranslations and routes every label through it: column headers, status, badges, and row actions.
The optimistic-archive machinery above this excerpt is carry-in and unchanged.
4 collapsed lines
'use client';
import { useTranslations } from 'next-intl';// …carry-in imports and the useOptimistic / useActionState setup above…
export const InvoicesTable = ({ rows, view, role,}: { rows: InvoiceRow[]; view: InvoiceView; role: Role;}) => { const t = useTranslations('invoices.list');
// …optimistic archive, lifecycle dispatchers, lifecycleFormData (unchanged)…
return ( <table data-testid="invoices-table" className="w-full text-sm"> <thead className="text-left text-muted-foreground"> <tr className="border-b"> <th className="py-2 font-medium">{t('columns.number')}</th> <th className="py-2 font-medium">{t('columns.customer')}</th> <th className="py-2 font-medium">{t('columns.status')}</th> <th className="py-2 text-right font-medium">{t('columns.amount')}</th> <th className="py-2" /> </tr> </thead> <tbody> {visibleRows.map((row) => { // …isActive / canDelete / canRestore / canUndelete (unchanged)…
return ( <tr key={row.id} data-testid="invoice-row" className="border-b"> {/* …number link (unchanged)… */} <td className="py-2"> <div className="flex flex-wrap items-center gap-2"> <span>{row.customerName}</span> {row.deletedAt ? ( <Badge data-testid="badge-deleted" variant="destructive"> {t('badge.deleted')} </Badge> ) : null} {row.archivedAt && !row.deletedAt ? ( <Badge data-testid="badge-archived" variant="secondary"> {t('badge.archived')} </Badge> ) : null} </div> {view === 'archived' && row.archivedAt ? ( <div data-testid="archived-on" className="text-xs text-muted-foreground" > Archived on {new Date(row.archivedAt).toLocaleDateString()} </div> ) : null} </td> <td data-testid="invoice-status" className="py-2"> {t(`status.${row.status}`)} </td> <td data-testid="invoice-amount" className="py-2 text-right tabular-nums" > {row.currency} {row.total} </td> {/* …row-actions dropdown: labels via t('actions.edit'), t('actions.archive'), t('actions.restore'), t('actions.undelete'), t('actions.delete'); the trigger's aria-label via t('actions.label')… */} </tr> ); })} </tbody> </table> );};The status cell is the one to study.
The carry-in rendered the raw status value with a capitalize class; t(`status.${row.status}`) looks it up by its own value instead, so 'sent' resolves invoices.list.status.sent and renders Sent in English, Envoyée in French.
The templated key keeps the cell declarative and lets the catalog own the words.
Two cells stay put.
The amount is still {row.currency} {row.total} and the archived-on line still leans on toLocaleDateString; useFormatter isn’t imported yet.
Those value cells move onto it next lesson by design.
messages/en-GB.json
Section titled “messages/en-GB.json”British English is a thin diff from the source catalog: copy en-US.json and change only the values that diverge, the spellings localised and time zone.
The augmented Messages type, generated from en-US.json, holds every catalog to the same keys, so the structure can’t drift.
{ "nav": { "brand": "Invoices", "list": "List", "inspector": "Inspector", "home": "Home", "pricing": "Pricing", "features": "Features", "app": "App" }, "locale-switcher": { "label": "Language", "en-US": "English (US)", "en-GB": "English (UK)", "fr-FR": "Français" }, "invoices": { "list": { "title": "Invoices", "count": "{count, plural, =0 {No invoices} one {# invoice} other {# invoices}}", "empty": "No invoices match these filters.", "selectPrompt": "Select an invoice to see its detail.",50 collapsed lines
"columns": { "number": "Number", "customer": "Customer", "status": "Status", "amount": "Amount", "date": "Date", "due": "Due" }, "status": { "draft": "Draft", "sent": "Sent", "paid": "Paid", "overdue": "Overdue" }, "tabs": { "active": "Active", "archived": "Archived", "all": "All" }, "toolbar": { "statusPlaceholder": "Status", "statusAll": "All statuses", "sortPlaceholder": "Sort", "sort": { "newest": "Newest first", "oldest": "Oldest first", "totalDesc": "Total: high to low", "totalAsc": "Total: low to high", "customerDesc": "Customer: Z–A", "customerAsc": "Customer: A–Z" }, "searchPlaceholder": "Search…" }, "pagination": { "label": "Pagination", "first": "First page", "next": "Next" }, "badge": { "deleted": "Deleted", "archived": "Archived" }, "actions": { "label": "Row actions", "edit": "Edit", "archive": "Archive", "restore": "Restore", "undelete": "Restore deleted", "delete": "Delete" } } }, "marketing": { "meta": { "home": { "title": "Invoices for teams that ship worldwide", "description": "A tri-locale, time-zone-aware invoices workspace — every string, currency, and date localised from day one." }, "pricing": { "title": "Pricing", "description": "Simple, transparent pricing in your currency and your language." }, "features": { "title": "Features", "description": "Locale routing, currency-from-data, time-zone-aware dates, and SEO-grade hreflang built in." } }, "home": { "heading": "Invoices, localised from day one", "subheading": "One workspace, three locales, every date in the viewer's time zone.", "cta": "View the invoices list" }, "pricing": { "heading": "Pricing" }, "features": { "heading": "Features" } }}This is where the key discipline pays off: a near-identical locale is a fifteen-key diff, not a fork.
The count message keeps English’s =0 / one / other shape, since British and American English share the same plural grammar; only the spellings move.
messages/fr-FR.json
Section titled “messages/fr-FR.json”French is a full translation. Most of it is word-for-word, but two parts carry the weight: the status labels and the counter.
{ "nav": { "brand": "Factures", "list": "Liste", "inspector": "Inspecteur", "home": "Accueil", "pricing": "Tarifs", "features": "Fonctionnalités", "app": "Application" }, "locale-switcher": { "label": "Langue", "en-US": "English (US)", "en-GB": "English (UK)", "fr-FR": "Français" }, "invoices": { "list": { "title": "Factures", "count": "{count, plural, =0 {Aucune facture} one {# facture} many {# de factures} other {# factures}}", "empty": "Aucune facture ne correspond à ces filtres.", "selectPrompt": "Sélectionnez une facture pour voir son détail.", "columns": { "number": "Numéro", "customer": "Client", "status": "Statut", "amount": "Montant", "date": "Date", "due": "Échéance" }, "status": { "draft": "Brouillon", "sent": "Envoyée", "paid": "Réglée", "overdue": "En retard" }, "tabs": { "active": "Actives", "archived": "Archivées", "all": "Toutes" }, "toolbar": { "statusPlaceholder": "Statut", "statusAll": "Tous les statuts", "sortPlaceholder": "Trier", "sort": { "newest": "Plus récentes d’abord", "oldest": "Plus anciennes d’abord", "totalDesc": "Montant : décroissant", "totalAsc": "Montant : croissant", "customerDesc": "Client : Z–A", "customerAsc": "Client : A–Z" }, "searchPlaceholder": "Rechercher…" }, "pagination": { "label": "Pagination", "first": "Première page", "next": "Suivant" }, "badge": { "deleted": "Supprimée", "archived": "Archivée" }, "actions": { "label": "Actions de la ligne", "edit": "Modifier", "archive": "Archiver", "restore": "Restaurer", "undelete": "Restaurer la supprimée", "delete": "Supprimer" } } }, "marketing": { "meta": { "home": { "title": "Des factures pour les équipes qui livrent dans le monde entier", "description": "Un espace de facturation trilingue et sensible au fuseau horaire — chaque texte, devise et date localisés dès le premier jour." }, "pricing": { "title": "Tarifs", "description": "Une tarification simple et transparente, dans votre devise et votre langue." }, "features": { "title": "Fonctionnalités", "description": "Routage par locale, devise issue des données, dates sensibles au fuseau horaire et hreflang de qualité SEO intégrés." } }, "home": { "heading": "Des factures, localisées dès le premier jour", "subheading": "Un espace, trois locales, chaque date dans le fuseau horaire du lecteur.", "cta": "Voir la liste des factures" }, "pricing": { "heading": "Tarifs" }, "features": { "heading": "Fonctionnalités" } }}The counter carries a fourth branch the English source lacks: many. CLDR routes large French numbers (1 000 000) through it so they read … de factures. =0 is an exact-match override that fires only at zero; other is the mandatory fallback.
{ "nav": { "brand": "Factures", "list": "Liste", "inspector": "Inspecteur", "home": "Accueil", "pricing": "Tarifs", "features": "Fonctionnalités", "app": "Application" }, "locale-switcher": { "label": "Langue", "en-US": "English (US)", "en-GB": "English (UK)", "fr-FR": "Français" }, "invoices": { "list": { "title": "Factures", "count": "{count, plural, =0 {Aucune facture} one {# facture} many {# de factures} other {# factures}}", "empty": "Aucune facture ne correspond à ces filtres.", "selectPrompt": "Sélectionnez une facture pour voir son détail.", "columns": { "number": "Numéro", "customer": "Client", "status": "Statut", "amount": "Montant", "date": "Date", "due": "Échéance" }, "status": { "draft": "Brouillon", "sent": "Envoyée", "paid": "Réglée", "overdue": "En retard" }, "tabs": { "active": "Actives", "archived": "Archivées", "all": "Toutes" }, "toolbar": { "statusPlaceholder": "Statut", "statusAll": "Tous les statuts", "sortPlaceholder": "Trier", "sort": { "newest": "Plus récentes d’abord", "oldest": "Plus anciennes d’abord", "totalDesc": "Montant : décroissant", "totalAsc": "Montant : croissant", "customerDesc": "Client : Z–A", "customerAsc": "Client : A–Z" }, "searchPlaceholder": "Rechercher…" }, "pagination": { "label": "Pagination", "first": "Première page", "next": "Suivant" }, "badge": { "deleted": "Supprimée", "archived": "Archivée" }, "actions": { "label": "Actions de la ligne", "edit": "Modifier", "archive": "Archiver", "restore": "Restaurer", "undelete": "Restaurer la supprimée", "delete": "Supprimer" } } }, "marketing": { "meta": { "home": { "title": "Des factures pour les équipes qui livrent dans le monde entier", "description": "Un espace de facturation trilingue et sensible au fuseau horaire — chaque texte, devise et date localisés dès le premier jour." }, "pricing": { "title": "Tarifs", "description": "Une tarification simple et transparente, dans votre devise et votre langue." }, "features": { "title": "Fonctionnalités", "description": "Routage par locale, devise issue des données, dates sensibles au fuseau horaire et hreflang de qualité SEO intégrés." } }, "home": { "heading": "Des factures, localisées dès le premier jour", "subheading": "Un espace, trois locales, chaque date dans le fuseau horaire du lecteur.", "cta": "Voir la liste des factures" }, "pricing": { "heading": "Tarifs" }, "features": { "heading": "Fonctionnalités" } }}The table’s templated status.<value> key reads labels straight from this block: 'sent' resolves to Envoyée, 'paid' to Réglée. A wrong key here drops the cell back to the raw status value.
The count message is the line that matters most, and the annotation above lays out its branches.
The trap is the many branch: drop it and 1000000 falls silently to other, losing the “de” so the count reads 1 000 000 factures instead of 1 000 000 de factures, grammatically wrong and invisible until a French speaker catches it.
# is the formatted count, with French’s space-grouped thousands.
The canonical reference for routing, the proxy/middleware, and the request config you wire in this lesson.
The plural argument syntax behind the count message, including the =0 / one / many / other categories.
The authoritative chart proving French routes 1,000,000 through the many category — the load-bearing trap.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 2The tests check observable behavior: which strings each catalog renders, which plural category fires per count, the <html lang> each prefix paints, and the writes the switch action makes.
A green run looks like this:
✓ lesson-verification/Lesson 2.ts (7 tests) ✓ Requirement 1 — every UI string renders from the catalog and swaps per locale ✓ Requirement 2 — the count fires the right CLDR plural category per locale ✓ Requirement 3 — <html lang> matches the URL prefix on every locale path ✓ Requirement 4 — the locale-switch action writes the store profile and the NEXT_LOCALE cookie
Test Files 1 passed (1) Tests 7 passed (7)The rest is browser and build behavior the tests can’t reach.
Boot pnpm dev and walk this list, reverting any breakage you introduce to probe a behavior.
/ loads marketing unprefixed and /fr-FR/ loads it in French; /invoices, /fr-FR/invoices, and /en-GB/invoices each route and render in their own language.many wording (… de factures) at 1,000,000 and both locales show the =0 text at zero. To prove the many branch is real, temporarily replace the French count message with "{count} factures": the probe loses the “de” at a million. Revert.locale, and sets NEXT_LOCALE; <html lang> (View Source) matches each prefix. Re-hardcode <html lang="en-US"> and the lang stops matching once you switch to French. Revert./ redirects to /fr-FR/; setting it to pt-BR (unsupported) loads / unprefixed and falls back to English.next build, the output reports the marketing routes as statically rendered. Delete the setRequestLocale(locale) call from a layout and rebuild — those routes flip to dynamic. Revert./invoices?status=paid&cursor=abc123, switching to French lands /fr-FR/invoices?status=paid&cursor=abc123.app/[locale]/ finds none. Drop a stray <button>Save</button> under it and the grep reports one hit. Revert. The locale also survives a refresh and a cookie clear on a prefixed URL — the URL prefix is the strongest signal in the chain.Amounts still print as EUR 1234.56 and dates stay unformatted — intentional, until the next lesson routes every value through one formatter.