Messages and i18n
All user-facing text goes through next-intl. Features ship default copy in five locales; this project owns the final wording.
Changing a feature’s copy is editing a file
Section titled “Changing a feature’s copy is editing a file”A feature owns a namespace and ships defaults under it. This project holds one committed
messages/<locale>.json per locale, feature defaults are generated into it, and whatever the
file already says wins. So changing a label — including an aria-label — is setting the key.
No slot, no wrapper, no code change, and no reason to fork a component over wording.
Copy the default does not have at all is a different problem: that is composition, not messages.
The pipeline
Section titled “The pipeline”Each messages/<locale>.json is the same artifact in all three roles: the file the app loads, the
source sent to the translation tool, and the file the tool updates by pull request. Nothing is
merged at runtime — a request loads one file and scopes it per route.
The generator’s key set is the union of the installed packages’ own message files and this
project’s useTranslations / getTranslations call sites. For every key:
| State | Action |
|---|---|
| The file already has a real value | Keep it, untouched |
| Absent or a placeholder, and the package ships a value | Fill from the package |
| Absent or a placeholder, and nothing ships one | Write a placeholder |
A placeholder is the key in brackets — "[Cart.badge.label]". The brackets are the point: a
bare key name is ambiguous, since free is a plausible English translation of the key free.
Bracketing makes untranslated a machine-detectable state that CI can count and gate on. A value is
untranslated if and only if it is bracketed.
An existing value is never overwritten. It was written by a developer, corrected by a translator, or merged from the tool’s pull request, and the generator cannot tell which. The consequence is that a feature’s later copy improvements do not propagate — so a changed package default is reported, not written, and the project decides.
Orphans are removed and every removal is printed. A key no package declares and no code uses is dead; a surprising removal belongs in the diff rather than in a later discovery that copy went missing.
Run pnpm codegen after adding or removing a useTranslations / getTranslations namespace. CI
fails on an out-of-date file, and a remaining placeholder in the source locale fails a release
build — English is authored, not translated, so a hole in it is unfinished work. Placeholders in
other locales are counted, not blocking.
Delivery: one provider per subtree, never nested
Section titled “Delivery: one provider per subtree, never nested”Messages handed to NextIntlClientProvider are serialized into the RSC payload — per response,
not per build. A server module that imports every catalog costs nothing; only what is passed to a
provider ships. The lever is what each provider receives.
NextIntlClientProvider treats messages as atomic: a nested provider replaces its parent’s
messages rather than merging them. locale, timeZone, formats and now are inherited;
messages are not. So a provider in the root layout wrapping {children} is the wrong shape — every
page’s provider would replace the shell’s, and either the header loses its copy or every page
re-sends the shell set.
Each provider wraps only its own subtree, as a sibling of the others:
// app/[locale]/layout.tsx — the shell's providers wrap the shell, not the childrenconst messages = await getMessages();
<NextIntlClientProvider messages={pick(messages, HEADER_NAMESPACES)}> <Header /></NextIntlClientProvider>;
{ children;}
<NextIntlClientProvider messages={pick(messages, FOOTER_NAMESPACES)}> <Footer /></NextIntlClientProvider>;// app/[locale]/cart/page.tsx — the page carries its own, complete set<NextIntlClientProvider messages={pick(messages, [CART_NAMESPACE, UI_NAMESPACE])}> <CartPage /></NextIntlClientProvider>Every client component must sit inside exactly one provider carrying every namespace it uses. Nothing is inherited across subtrees, so each set is complete on its own — which is what makes the scoping auditable one file at a time.
The page or the sublayout owns its picked set, colocated with the markup it serves. A sublayout is
the right home for a namespace shared by a route group but not the whole site — /account/*
carrying account copy once around its own subtree rather than in each of its pages.
Picks reach below the namespace. The cart badge needs cart copy on every route, and taking the
whole Cart namespace into the header would undo the benefit:
{ Cart: { badge: messages.Cart.badge; }}The merged catalog is a plain object, so a pick can address any depth. The cart page takes all of
Cart in its own provider, independently.
Namespaces are constants, not string literals. Import the name the feature exports, so a rename is a type error rather than a silent runtime one and every consumer is greppable.
import { CART_NAMESPACE } from "@evolve-storefront/feature-cart/messages";next-intl throws on a missing message, so an omitted namespace fails loudly in development and CI. It does not catch the opposite drift — a namespace still picked after the component that needed it was removed — so each route’s provider set is pinned by a test.
Routing: canonical keys, localized URLs
Section titled “Routing: canonical keys, localized URLs”The canonical internal path is the stable contract; the localized external URL is this project’s to remap. Routing config is the site’s own.
export const pathnames = { "/account": "/account", "/cart": { "de-DE": "/warenkorb", "nl-NL": "/winkelwagen", "en-GB": "/cart" },};A feature renders <Link href="/cart"> using the canonical key and never sees the localized
string. Renaming a route, restructuring it or dropping a language is a different external mapping —
no feature code changes. The pathnames map must cover the merged key set of every installed
feature, so a missing key is a compile error.
Features import their navigation surface from a stable specifier and ship a default binding so they
run standalone; this site supplies the concrete createNavigation(routing) binding. Pointing the
packages at it is an alias, which is the escape hatch — see evolve-site-theming.
Locale lives in the URL path, never a per-user cookie. That is what keeps anonymous pages CDN-cacheable.
Adding a locale outside the five features ship (en, nl, de, fr, es) means placeholders for
every feature key in it. That is a deliberate batch of work and belongs at the start of a project
rather than partway through.

