Internationalization (i18n)
Locale-prefixed routing, typed dictionaries, and adding a new language.
The storefront ships with a lightweight, dependency-free i18n layer. Every
shopper-facing string resolves through one keyed dictionary, so translating the
store is a data change (drop in a dictionary file) rather than a code change.
English (en) is the default and Spanish (es) ships alongside it.
The i18n layer covers the shopper-facing storefront only. The admin dashboard, transactional emails, and legal page bodies are intentionally not dictionary-driven — they are internal or long-form copy, not part of the translated surface.
How it fits together
Two things move in lockstep to localize the app:
- Locale-prefixed routing — every storefront and admin page lives under
src/app/[locale]/…. The URL segment (/en/products,/es/cart) is the source of truth for the active locale. - The dictionary layer (
src/lib/i18n) — a keyed set of typed dictionaries plust()helpers that components read instead of hardcoding copy.
A locale cookie remembers the shopper's choice so returning visitors and the
middleware agree on which locale to serve.
| File | Responsibility |
|---|---|
src/middleware.ts | Redirects unprefixed paths to the visitor's locale |
src/app/[locale]/layout.tsx | Root layout: sets <html lang>, mounts LocaleProvider |
src/lib/i18n/index.ts | Runtime-neutral core: dictionaries, translate, resolveLocale |
src/lib/i18n/LocaleProvider.ts | Client runtime: React context, cookie writes, useTranslations |
src/lib/i18n/server.ts | Server helpers: getActiveLocale, getServerDictionary |
src/lib/i18n/navigation.ts | localizeHref / useLocalePath for locale-aware links |
src/lib/i18n/en.ts, es.ts | The dictionaries |
src/components/i18n/LocaleSwitcher.tsx | The globe dropdown switcher |
Locale-prefixed routing
Every page lives under the [locale] dynamic segment. The layout pre-renders one
route tree per configured locale and rejects unknown prefixes:
export function generateStaticParams(): { locale: string }[] {
return siteConfig.locales.map((locale) => ({ locale: locale.code }));
}
export default function RootLayout({ children, params }: RootLayoutProps) {
// The URL is the source of truth for locale. Reject unknown prefixes so
// `/fr/...` 404s instead of silently rendering the default locale.
if (!siteConfig.locales.some((locale) => locale.code === params.locale)) {
notFound();
}
const locale = resolveLocale(params.locale);
// ...
return (
<html lang={locale}>
{/* ... LocaleProvider seeded with initialLocale={locale} ... */}
</html>
);
}Because there is no src/app/layout.tsx, this [locale] layout is the root
layout — it owns <html>/<body> and every top-level provider. The
<html lang> attribute always follows the URL segment, so screen readers and
search engines see the correct language for each page.
The redirect middleware
src/middleware.ts makes the locale prefix mandatory. Unprefixed requests
(/, /products, …) are 307-redirected to the visitor's active locale;
already-prefixed paths pass straight through (a guard prevents redirect loops).
Resolution order
When a request has no locale prefix, the middleware picks one in this order:
locale cookie — used when it holds a known locale code.
Accept-Language header — the best match parsed from the browser's
language preferences, honoring q weighting and matching on both the full tag
(es-ES) and its primary subtag (es).
defaultLocale — siteConfig.defaultLocale ("en") as the final fallback.
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
const locale =
(cookieLocale && localeCodes.includes(cookieLocale) ? cookieLocale : null) ??
localeFromAcceptLanguage(request.headers.get("accept-language")) ??
defaultLocale;
url.pathname = pathname === "/" ? `/${locale}` : `/${locale}${pathname}`;
return NextResponse.redirect(url, 307);The middleware never writes the locale cookie — that is owned by the client
LocaleProvider. The middleware only reads it. It is also kept dependency-free
(no intl-localematcher) so it stays small at the edge.
The middleware skips API routes, the (non-localized) /docs subtree, Next.js and
Vercel internals, any path with a file extension, and the SEO/meta files:
export const config = {
matcher: [
"/((?!api|docs|_next|_vercel|.*\\..*|sitemap\\.xml|robots\\.txt|favicon\\.ico).*)",
],
};Translating copy with t()
Components read strings through a bound translate function t("group.key", vars).
Keys are dot-paths resolved against the active dictionary, and values may contain
{name} placeholders interpolated from a vars object.
useTranslations() reads the active locale from the nearest LocaleProvider
(falling back to English when none is mounted). The result is memoized, so t is
referentially stable across renders.
"use client";
import { useTranslations } from "@/lib/i18n";
export function AddButton() {
const { t, locale } = useTranslations();
return <Button>{t("common.addToCart")}</Button>;
}Server code has no React hooks. Read the request's dictionary from the cookie via
getServerDictionary(), then bind a translator with createTranslator().
import { createTranslator } from "@/lib/i18n";
import { getServerDictionary } from "@/lib/i18n/server";
const t = createTranslator(getServerDictionary());
t("home.featuredProducts");
t("product.onlyNLeft", { n: 3 }); // interpolates {n}Resilient lookups
translate() never throws. If a key does not resolve to a string it returns the
key itself — prefixed with ⚠ in development so misses are obvious in the UI —
so a missing translation can never crash a render:
if (typeof resolved !== "string") {
return process.env.NODE_ENV === "development" ? `⚠ ${key}` : key;
}Core API reference
All of these are exported from @/lib/i18n (index.ts) unless noted:
| Export | Signature | Purpose |
|---|---|---|
dictionaries | { en, es } | Registry of available locales |
Locale | keyof typeof dictionaries | Supported locale union ("en" | "es") |
Dictionary | typeof en | Shape derived from the English source of truth |
defaultLocale | Locale | Default locale from siteConfig ("en") |
LOCALE_COOKIE | "locale" | Cookie name for the shopper's chosen locale |
resolveLocale(value) | (string | null | undefined) => Locale | Narrow any string to a known locale, else defaultLocale |
getDictionary(locale?) | (Locale?) => Dictionary | Resolve a dictionary, falling back to default |
translate(dict, key, vars?) | (Dictionary, string, TranslationVars?) => string | Resolve a dot-path key + interpolate |
createTranslator(dict) | (Dictionary) => TFunction | Bind a t(key, vars) to a dictionary |
useTranslations() | () => { t, locale } | Client hook, from LocaleProvider.ts |
useLocale() | () => LocaleContextValue | null | Client hook: { locale, setLocale } |
getActiveLocale() | () => Locale | Server: read locale from cookie (server.ts) |
getServerDictionary() | () => Dictionary | Server: dictionary for the request (server.ts) |
src/lib/i18n/server.ts imports next/headers and is server-only — never
import it from a client component. The client counterpart is
LocaleProvider.ts; the shared, runtime-neutral primitives live in index.ts.
Locale-aware links
With locale-prefixed routing, every internal link must carry the active locale.
src/lib/i18n/navigation.ts provides localizeHref (a pure, server-safe
function) and useLocalePath (a client hook bound to the active locale):
import { useLocalePath } from "@/lib/i18n/navigation";
const localize = useLocalePath();
<Link href={localize("/products")}>Products</Link>; // → /en/productslocalizeHref is idempotent and leaves external, hash, mailto:, tel:, and
protocol-relative links untouched; / maps to /{locale}. Server components
(Footer, Hero) call localizeHref(href, locale) with a locale threaded down from
their page's route param.
The locale cookie and switcher
LocaleSwitcher (src/components/i18n/LocaleSwitcher.tsx) is a globe dropdown
listing siteConfig.locales. It reads and writes the active locale through
useLocale() and renders nothing when no LocaleProvider is mounted. In the
storefront it is surfaced inside the header Preferences menu
(src/components/preferences/PreferencesMenu.tsx), alongside currency and theme.
Choosing a locale calls setLocale, which:
Persists the choice to a first-party locale cookie (path=/,
max-age = 1 year, samesite=lax).
Navigates to the same page under the new locale by swapping the leading
/{oldLocale} segment for the new one, preserving the query string.
Because the cookie is written client-side, the next unprefixed request the middleware sees will resolve to the shopper's chosen locale.
Adding a new language
The shipped locales are declared in siteConfig:
defaultLocale: "en",
locales: [
{ code: "en", label: "English" },
{ code: "es", label: "Español" },
] satisfies LocaleOption[],To add, say, French (fr):
Create the dictionary. Copy src/lib/i18n/en.ts to src/lib/i18n/fr.ts,
translate the string values, and end the object with satisfies typeof en so
TypeScript flags any missing, extra, or misspelled key. Keep the nested shape
identical and preserve every {placeholder} verbatim.
import type { en } from "@/lib/i18n/en";
export const fr = {
common: {
addToCart: "Ajouter au panier",
// ...every group: nav, footer, home, about, product, catalog,
// search, cart, checkout, auth, account
},
} satisfies typeof en;Register it in the dictionaries map. The Locale union widens
automatically.
import { fr } from "@/lib/i18n/fr";
export const dictionaries = { en, es, fr } as const;Add it to siteConfig.locales so it appears in the switcher and passes
resolveLocale validation.
locales: [
{ code: "en", label: "English" },
{ code: "es", label: "Español" },
{ code: "fr", label: "Français" },
] satisfies LocaleOption[],That's it — generateStaticParams will now pre-render an /fr/… route tree, the
middleware will match fr in Accept-Language, and the switcher will offer it.
The dictionary is organized into these top-level groups: common, nav,
footer, home, about, product, catalog, search, cart, checkout,
auth, and account. The satisfies typeof en assertion on each non-English
file is your compile-time guarantee that a translation stays complete.