Acme Store Docs
Configuration

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:

  1. 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.
  2. The dictionary layer (src/lib/i18n) — a keyed set of typed dictionaries plus t() 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.

FileResponsibility
src/middleware.tsRedirects unprefixed paths to the visitor's locale
src/app/[locale]/layout.tsxRoot layout: sets <html lang>, mounts LocaleProvider
src/lib/i18n/index.tsRuntime-neutral core: dictionaries, translate, resolveLocale
src/lib/i18n/LocaleProvider.tsClient runtime: React context, cookie writes, useTranslations
src/lib/i18n/server.tsServer helpers: getActiveLocale, getServerDictionary
src/lib/i18n/navigation.tslocalizeHref / useLocalePath for locale-aware links
src/lib/i18n/en.ts, es.tsThe dictionaries
src/components/i18n/LocaleSwitcher.tsxThe 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:

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

defaultLocalesiteConfig.defaultLocale ("en") as the final fallback.

src/middleware.ts
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:

src/middleware.ts
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.

Client component
"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().

Server component
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:

src/lib/i18n/index.ts
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:

ExportSignaturePurpose
dictionaries{ en, es }Registry of available locales
Localekeyof typeof dictionariesSupported locale union ("en" | "es")
Dictionarytypeof enShape derived from the English source of truth
defaultLocaleLocaleDefault locale from siteConfig ("en")
LOCALE_COOKIE"locale"Cookie name for the shopper's chosen locale
resolveLocale(value)(string | null | undefined) => LocaleNarrow any string to a known locale, else defaultLocale
getDictionary(locale?)(Locale?) => DictionaryResolve a dictionary, falling back to default
translate(dict, key, vars?)(Dictionary, string, TranslationVars?) => stringResolve a dot-path key + interpolate
createTranslator(dict)(Dictionary) => TFunctionBind a t(key, vars) to a dictionary
useTranslations()() => { t, locale }Client hook, from LocaleProvider.ts
useLocale()() => LocaleContextValue | nullClient hook: { locale, setLocale }
getActiveLocale()() => LocaleServer: read locale from cookie (server.ts)
getServerDictionary()() => DictionaryServer: 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.

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):

Client component
import { useLocalePath } from "@/lib/i18n/navigation";

const localize = useLocalePath();
<Link href={localize("/products")}>Products</Link>; // → /en/products

localizeHref 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.

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:

src/config/site.ts
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.

src/lib/i18n/fr.ts
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.

src/lib/i18n/index.ts
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.

src/config/site.ts
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.

On this page