Skip to content
Chapter 85Lesson 2

Wire next-intl and ship three catalogs

The starter routes but doesn’t speak. Visit /invoices and you get English. Visit /fr-FR/invoices and you still get English — the URL prefix changes, the [locale] segment matches, but every string on the page 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.

Here’s what working looks like. The unprefixed / loads the default-locale marketing page; /fr-FR/ loads it in French. /invoices, /fr-FR/invoices, and /en-GB/invoices each route and render in their own language — French column headers, French status labels, the British spelling of “localised”. The language switcher in the header rewrites the URL and the choice sticks across navigation. And the “N invoices” counter reads naturally in each language: No invoices / 1 invoice / 5 invoices in US English, and Aucune facture / 1 facture / 1 000 000 de factures in French — note that last one, the French branch for large numbers, which is the part that trips up almost everyone the first time.

One thing stays unfinished on purpose. The amount still renders as EUR 1234.56 and the dates still come out in whatever shape toLocaleDateString hands back. That’s not a bug — currency and date formatting are the next lesson’s job, where they move onto a single formatter seam. This lesson is about the spine: locale resolved once, every string read from a per-locale catalog, the right plural category per language.

You’re installing the canonical 2026 internationalization spine onto the carry-in invoices list, and the shape matters more than the keystrokes. Locale is resolved exactly once, upstream, in middleware — your code downstream only ever reads the resolved value. Every visible string flows out of a per-locale JSON catalog through a translation function, so en-US.json becomes the source contract and the other two catalogs are translations of it. There are a few traps an inexperienced engineer walks straight into here, and avoiding them is 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 a type-safety trap: the request can carry any string in the locale position, including garbage, so hasLocale(routing.locales, requested) is the validator that both narrows the type and lets the layout bail out with notFound() cleanly instead of rendering a broken page. The third is payload bloat: the client provider must be scoped to only the three namespaces client components actually read — not the full three-locale catalog — and the request config dynamic-imports the active locale’s JSON so production ships only the catalog in use, not all three.

The switch action carries its own discipline. It writes two signals, not one: the user’s profile in the store and the NEXT_LOCALE cookie, with sameSite: 'lax'. Both have to agree so the choice survives navigation and the negotiation chain reads it back consistently. And the load-bearing trap of the whole lesson: the CLDR plural rules give French a many category for large numbers, so an ICU plural message that ships only one and other will silently mistranslate 1000000. The counter uses =0 / one / many / other, and it is never, ever a ternary.

Out of scope: date and currency formatting (next lesson), translation-management-system wiring (the catalogs check into the repo in the exact format tools like Crowdin, Lokalise, or Phrase round-trip, but you wire no TMS here), and the SEO metadata surface (the lesson after next). A few 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, which is Next.js 16’s rename of middleware.ts and holds the whole negotiation chain.

Every UI string on the invoice surface renders from the catalog rather than hard-coded JSX, and switching locale swaps every string — French strings appear at /fr-FR/invoices, English at /invoices.
tested
The “N invoices” counter renders the correct CLDR category per locale across 0, 1, 5, and 1,000,000: en-US shows No invoices / 1 invoice / 5 invoices; fr-FR shows Aucune facture / 1 facture / 1 000 000 de factures (the many branch).
tested
<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.
tested
The locale-switch action updates the acting store user’s locale and sets the NEXT_LOCALE cookie.
tested
/ 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.
untested
An anonymous browser sending Accept-Language: fr-FR is redirected from / to /fr-FR/; an unsupported pt-BR loads / unprefixed and falls back to en-US.
untested
Marketing routes stay statically rendered after next build — a missing setRequestLocale flips them dynamic, and there’s no panel for this, so you verify it by hand.
untested
The switcher preserves path and query: /invoices?status=paid&cursor=abc123 becomes /fr-FR/invoices?status=paid&cursor=abc123.
untested

Implement against the brief above and the lesson tests. The walkthrough below is here to read after you’ve taken a real swing at it — open it once you’ve got something working or you’re genuinely stuck.

Reference solution and walkthrough

The work spans eight files. We’ll go in the order it makes sense to build them: the request config and the format presets first (the foundation everything reads), then the layout and the switch action (the routing plumbing), then the page and table (the strings), and finally the two translation catalogs (the payoff).

This is the seam next-intl evaluates once per request to decide which catalog to load. The starter hard-codes the en-US catalog for every locale — that’s why the prefix has no visible effect. The fix is two characters of interpolation.

src/i18n/request.ts
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 };
});

Two decisions are doing the heavy lifting here. The dynamic import(`../messages/${locale}.json`) code-splits each catalog into its own chunk, so production loads only the active locale’s JSON instead of bundling all three. And hasLocale(routing.locales, requested) validates the segment against the known locale set before it’s used — the raw request is never trusted.

The thing that’s not here is just as important. This config returns only { locale, messages, formats } — no session read, no timeZone, no now. That’s deliberate. getRequestConfig runs during the static prerender of every locale in generateStaticParams, and any request-tied read inside it — cookies(), new Date() — fails that prerender with the classic “Uncached data was accessed outside of <Suspense>” error. Keeping it prerender-safe is exactly what lets the static locale shell build. The user’s timezone gets read at the formatter call site in the next lesson, not here.

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.

src/i18n/formats.ts
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;

A couple of things worth flagging. The number.currency preset arrives in the next lesson, when amounts move onto the formatter — there’s nothing currency-related here yet. There’s also no relativeTime key, and there can’t be: next-intl’s Formats type only has slots for dateTime, number, list, and displayName, so adding a relativeTime key fails tsc. And notice the presets are named at the config layer, not inlined at each call site — that’s the whole point of having them.

The shell is already wired — generateStaticParams, setRequestLocale, the NuqsAdapter, and a small pick helper are all provided. Your job is two lines: drive <html lang> from the resolved param, and scope the client provider. Here’s the before and after.

src/app/[locale]/layout.tsx
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 through NextIntlClientProvider.

Three things to understand about those two lines. setRequestLocale(locale) comes before getMessages() — and that ordering is load-bearing, not stylistic. It’s the call that opts this segment into static rendering; place any next-intl call ahead of it and the segment goes dynamic. <html lang={locale}> is driven from the resolved URL param, never from the cookie — if you read the cookie here, the server and the client can disagree about the language on first paint and you get a hydration mismatch. (The suppressHydrationWarning is there only for the theme class that next-themes injects, not as a license to mismatch the lang.)

The scoped provider is the payload win. pick(messages, ['invoices', 'nav', 'locale-switcher']) ships only the three namespaces that client components read — the table’s labels, the nav, the language switcher. The marketing copy and the metadata strings stay server-only and never cross the wire. Without the pick, you’d serialize the entire catalog to every client.

The starter returns err('internal', 'Not implemented'). Fill the body so the switch writes both signals.

src/app/[locale]/(app)/invoices/actions.ts
'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 body writes both halves of the choice. setUserLocale(ctx.userId, input.locale) updates the store profile, which the negotiation chain reads in its profile step; the cookie write feeds the chain’s cookie step. They have to agree, or the URL and the session can drift apart. sameSite: 'lax' is the right setting for a locale cookie — it’s sent on top-level navigations, which is all you need, and it doesn’t break OAuth callbacks the way 'strict' would. (A locale preference is GDPR “essential for functionality,” so it needs no consent banner.) The name NEXT_LOCALE isn’t arbitrary either — it’s the cookie next-intl reads by default.

You don’t write the switcher itself; 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. Read it to see how the two pieces connect, but leave it alone.

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 the 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 the invoices.list namespace.

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 heading is no longer the hard-coded "Invoices"t('title') resolves invoices.list.title from the active catalog.

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.

1 / 1

getTranslations('invoices.list') is the server-side translator, scoped to the invoices.list namespace so every key is read relative to it — t('title') resolves invoices.list.title. The count goes through t('count', { count: rows.length }), and the count value drives the ICU plural rule inside the catalog. That’s the whole point: the page passes a number, and the catalog decides — per locale — whether that number reads as singular, plural, or French’s many. The page has no idea which branch fires, and it shouldn’t.

The table is a client component, so it uses the hook form, useTranslations. Every label — column headers, status, badges, row actions — routes through it. Here’s the relevant excerpt; the optimistic-archive machinery above it is carry-in and unchanged.

src/app/[locale]/(app)/invoices/table.tsx
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 look at closely. The carry-in rendered the raw status value with a capitalize class — t(`status.${row.status}`) replaces that, looking up the status by its own value, so 'sent' resolves invoices.list.status.sent and comes out Sent in English, Envoyée in French. That’s a templated key, and it’s exactly the pattern you want: the cell stays declarative, and the catalog owns the words.

Notice what hasn’t changed. The amount is still {row.currency} {row.total} and the archived-on line still leans on toLocaleDateString. Those value cells move onto useFormatter next lesson — the hook isn’t even imported here yet. Keeping them as-is is the honest L2 state, not an oversight.

British English is a thin diff from the source catalog. Copy en-US.json, then change only the values that actually diverge — the spellings (localised, time zone) — and leave every other value identical. The key shape stays the same; the augmented Messages type (generated from en-US.json) enforces that every catalog has exactly the same keys, so you can’t drift the structure even if you wanted to.

src/messages/en-GB.json
{
"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 the case the discipline pays off in: a near-identical locale is a fifteen-key diff, not a fork. The count message keeps English’s =0 / one / other shape because British and American English share the same plural grammar — only the spellings move.

French is a full translation. Most of it is straightforward 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 doesn’t: many. CLDR routes large French numbers (1 000 000) through it so they read … de factures. The =0 branch is an exact-match override that fires only at zero, and 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 status labels the table’s templated status.<value> key reads straight from this block: 'sent' resolves to Envoyée, 'paid' to Réglée. Get a key wrong here and the cell falls back to the raw status value.

1 / 1

The counter is the line that matters most:

"count": "{count, plural, =0 {Aucune facture} one {# facture} many {# de factures} other {# factures}}"

The English source ships =0 / one / other — three branches. French adds a fourth, many, because CLDR’s plural rules route large French numbers through a separate category that inserts “de”: 1 000 000 de factures, not 1 000 000 factures. Drop the many branch and 1000000 silently falls to other and loses the “de” — grammatically wrong, and invisible until a French speaker catches it. The =0 branch is an exact-match override that fires only at zero, ahead of the category rules, so empty reads as Aucune facture rather than the awkward “0 facture”. The # is the formatted count (with French’s space-grouped thousands), and other is mandatory — every ICU plural message must have it as the fallback.

The status labels are the other thing to get right, because the templated t(`status.${row.status}`) key in the table reads straight from this block: Brouillon, Envoyée, Réglée, En retard. Get a key wrong here and the cell falls back to the raw status value.

If the ICU plural syntax or the CLDR categories feel shaky, that’s covered in depth in the Internationalization chapter — this lesson assumes you’ve met them. Same for the feature.surface.role key-naming convention these catalogs follow.

Run the lesson’s test suite:

pnpm test:lesson 2

The tests target observable behavior, not your file layout: which strings each catalog renders through next-intl’s ICU engine, which plural category fires per count, the <html lang> your layout paints for each prefix, and the profile-plus-cookie writes the switch action makes. A green run looks like this:

pnpm test:lesson 2
✓ 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 tests cover the catalogs, the plural categories, the <html lang> values, and the action’s writes. The rest is browser and build behavior the tests can’t reach — boot pnpm dev and walk this list, reverting any deliberate 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.
untested
In the inspector’s pluralization probe, type 0, 1, 2, 5, and 1000000 per locale — French shows the 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.
untested
The header switcher lands on the prefixed URL, updates the store user’s locale, and sets NEXT_LOCALE; <html lang> (View Source) matches each prefix. Re-hardcode <html lang="en-US"> and watch the lang stop matching once you switch to French. Revert.
untested
In a private window with browser language set to French, visiting / redirects to /fr-FR/; setting it to pt-BR (unsupported) loads / unprefixed and falls back to English.
untested
After next build, the build output reports the marketing routes as statically rendered. Delete the setRequestLocale(locale) call from a layout and rebuild — those routes flip to dynamic. Revert.
untested
The switcher preserves path and query: at /invoices?status=paid&cursor=abc123, switching to French lands /fr-FR/invoices?status=paid&cursor=abc123.
untested
A grep for hard-coded JSX strings inside app/[locale]/ finds none. Drop a stray <button>Save</button> somewhere 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.
untested

One last thing, so it doesn’t read as a regression: the amounts still come out as EUR 1234.56 and the dates are unformatted. That’s intentional. The next lesson moves every value cell onto a single formatter seam — currency that follows the viewer’s locale, dates in the user’s own timezone — and that’s where this surface starts to look finished.