enable-i18n.mdx---name: enable-i18ndescription: > Enable next-intl-based i18n in the shop template — locale-prefixed URLs, per-locale message catalogs, and a locale switcher. Use when the user wants "locale URLs", "multi-language", or "i18n" without Shopify Markets integration. For full Shopify Markets multi-region commerce (region-aware pricing, inventory, payments), use `enable-shopify-markets` instead — this skill is the routing/i18n layer only.---# Enable i18n (next-intl, no Markets)Wire next-intl into the template so the storefront serves locale-prefixed URLs (`/en-US/products/foo`), loads per-locale message catalogs, and exposes a locale switcher. The template ships single-locale by default with clean URLs (`/products/foo`) — this skill restores the i18n machinery.> **Use `enable-shopify-markets` instead** if you want region-aware pricing/inventory/payments. That skill builds on the same routing layer plus Markets-specific operations. If you only want URL prefixing and translated copy, this skill is the right one.## Source of truth: `lib/i18n/index.ts`The locale list lives in `lib/i18n/index.ts` as `locales` and `enabledLocales`. **Always read those at the start of the skill** — don't hardcode a list. Adding new locales means editing that file plus the `localeCurrency` map; everything downstream (`routing`, sitemap, alternates, switcher) reads from it.```ts// lib/i18n/index.tsexport const locales = ["en-US", "en-GB", "de-DE", "fr-FR"] as const;export const defaultLocale: Locale = "en-US";export const enabledLocales: readonly Locale[] = locales;```## What this skill turns on1. `lib/i18n/routing.ts` and `lib/i18n/navigation.ts` (next-intl)2. Route segment `app/[locale]/` containing every page3. `proxy.ts` middleware running `next-intl/middleware`4. `lib/params.ts` `getLocale()` reading from `next/root-params`5. `lib/i18n/request.ts` loading messages by resolved locale6. Locale-prefixed canonicals + hreflang alternates in `lib/seo.ts`7. Sitemap entries per locale8. `next.config.ts` rewrites/redirects on `/:locale/*` sources9. `app/(unlocalized)/page.tsx` fallback redirect to default locale10. `generateStaticParams` on the root layout11. (If `enable-shopify-menus` already ran) Re-enable `LocaleCurrencySelector` in the megamenu## Cache Components compatibility — read this firstThe template runs with `cacheComponents: true` (Next.js 16). That changes a few things this skill needs to handle correctly. Skipping any of these will produce build errors that look unrelated:### A. There must be no `app/layout.tsx` above `app/[locale]/`For `[locale]` to be recognized as a root param, the dynamic segment must be the root layout. After Step 2, the file at `app/layout.tsx` should be gone (moved into `app/[locale]/layout.tsx`). If both exist, `rootParams.locale()` returns `undefined`.### B. `setRequestLocale` is not usednext-intl docs sometimes show `setRequestLocale(locale)` calls in layouts/pages. **Don't add them under cacheComponents.** That helper writes to a request-scoped store and forces dynamic rendering — it defeats the cache. The rootParams + request-config pattern below makes it unnecessary because the resolved locale is already a cache key.### C. Don't swap `next/link` to next-intl's `<Link>`The straightforward instinct is to replace every `import Link from "next/link"` with `import { Link } from "@/lib/i18n/navigation"`. **Don't.** next-intl's Link reads request context (locale) on render; in a server-component tree under cacheComponents, that triggers:```Error: Route "/[locale]/..." accessed [...] which is not defined in the `unstable_samples` of `instant`.```or a generic "blocking route" prerender failure.**Do this instead:** keep `next/link` and let `proxy.ts` middleware redirect unprefixed paths (`/products/foo` → `/en-US/products/foo`). Internal links work; there's a one-time middleware redirect on click for unprefixed hrefs. Trade a few redirects for a clean prerender.If you must locale-prefix a programmatic URL (server actions, `redirect()`, `permanentRedirect()`), build the path yourself: `` `/${await getLocale()}/account/login` ``.### D. `instant` samples need `locale` in `params`Any route that exports `instant` (currently: products `[handle]`, collections `[handle]`, search) needs `locale` added to every sample, or the build fails:```Error: Route "/[locale]/products/[handle]" accessed root param "locale" which is not defined in the `unstable_samples` of `instant`.```Fix:```tsexport const instant = { unstable_samples: [ { params: { locale: "en-US", handle: "__placeholder__" }, // ← add locale searchParams: { variant: "1" }, cookies: [{ name: "shopify_cartId", value: null }], }, ],};```### E. `instant` samples need `headers` declarations if any layout-level server component reads `headers()`This is easy to forget. If you (or a downstream skill) adds a server component to the layout that calls `headers()` — e.g. a "Shipping to {postal}" bar reading `x-vercel-ip-postal-code` — every `instant` sample in the app must declare the headers it might access:```tsunstable_samples: [ { params: { locale: "en-US", handle: "__placeholder__" }, searchParams: { variant: "1" }, cookies: [{ name: "shopify_cartId", value: null }], headers: [["x-vercel-ip-postal-code", null]], // ← add this },],````null` means "header may be absent." If you forget, the build error is explicit:```Error: Route "..." accessed header "x-vercel-ip-postal-code" which is not defined in the `unstable_samples` of `instant`. Add it to the sample's `headers` array, or `["...", null]` if it should be absent.```### F. `redirect()` from next-intl doesn't return `never````ts// BREAKS: TS doesn't narrow `session` after redirectimport { redirect } from "@/lib/i18n/navigation";if (!session) redirect({ href: "/account/login", locale });return session; // type error: session is CustomerSession | null```next-intl's `redirect` is typed to return `void`, so TypeScript doesn't treat it as control-flow-ending. Use `next/navigation`'s `redirect` (which returns `never`) and prefix the locale yourself:```tsimport { redirect } from "next/navigation";import { getLocale } from "@/lib/params";if (!session) redirect(`/${await getLocale()}/account/login`);return session; // OK, narrowed```## Step-by-step### Step 1: Routing configCreate `lib/i18n/routing.ts`:```tsimport { defineRouting } from "next-intl/routing";import { defaultLocale, enabledLocales } from ".";export const routing = defineRouting({ locales: enabledLocales, // pulled from lib/i18n/index.ts — never hardcode defaultLocale, localePrefix: "always",});```Create `lib/i18n/navigation.ts`:```tsimport { createNavigation } from "next-intl/navigation";import { routing } from "./routing";export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);```> Per "Cache Components compatibility C" above, `Link` here is mostly used by the locale switcher / programmatic routing in client components — not as a wholesale replacement for `next/link`.### Step 2: Move routes under `app/[locale]/`Move every route file from `app/` into `app/[locale]/`:- `app/layout.tsx` → `app/[locale]/layout.tsx` (becomes the root layout for the locale segment). **Delete the original `app/layout.tsx` after the move** — see compatibility A above; both files cannot coexist.- `app/page.tsx`, `app/error.tsx`, `app/not-found.tsx` → `app/[locale]/...`- `app/about/`, `app/account/`, `app/cart/`, `app/collections/`, `app/products/`, `app/search/` → `app/[locale]/...`**Stay at `app/`:** `api/`, `sitemap.xml/`, `sitemap/`, `robots.ts`, `global-error.tsx`, `globals.css`, `favicon.ico`.In the moved layout, fix `import "./globals.css"` → `import "../globals.css"`.Update every `PageProps<"/foo">` and `LayoutProps<"/foo">` generic to include the locale segment: `PageProps<"/[locale]/products/[handle]">`, `LayoutProps<"/[locale]">`, etc.### Step 3: `lib/params.ts` reads from root params```tsimport { notFound } from "next/navigation";import { locale as rootLocale } from "next/root-params";import { type Locale, locales } from "./i18n";export async function getLocale(): Promise<Locale> { const current = await rootLocale(); if (!current || !locales.includes(current as Locale)) notFound(); return current as Locale;}```### Step 4: `lib/i18n/request.ts` loads messages by resolved locale```tsimport { hasLocale } from "next-intl";import { getRequestConfig } from "next-intl/server";import { getLocale } from "../params";import type enMessages from "./messages/en.json";import { routing } from "./routing";const messageLoaders: Record<string, () => Promise<{ default: typeof enMessages }>> = { "en-US": () => import("./messages/en.json"), // Add per-locale loaders as you ship message files. Missing locales fall // back to the default locale loader.};// We intentionally do NOT destructure `{ locale }` from the callback args.// next-intl populates that arg from the `x-next-intl-locale` request header,// and reading request headers from inside a cached tree forces the route// dynamic — every `instant` sample then needs an explicit// `headers: [["x-next-intl-locale", null]]` declaration. Going straight to// `getLocale()` (which reads `next/root-params`) keeps the lookup cacheable.export default getRequestConfig(async () => { const requested = await getLocale(); const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale; const loader = messageLoaders[locale] ?? messageLoaders[routing.defaultLocale]; const messages = (await loader()).default as typeof enMessages; return { locale, messages };});```### Step 5: `proxy.ts` middleware```tsimport createMiddleware from "next-intl/middleware";import { type NextRequest, NextResponse } from "next/server";import { routing } from "@/lib/i18n/routing";const handlei18n = createMiddleware(routing);export default function middleware(request: NextRequest): NextResponse { const response = handlei18n(request); if (!response.ok) return response; const rewriteHeader = response.headers.get("x-middleware-rewrite"); if (!rewriteHeader) return response; const rewriteTarget = new URL(rewriteHeader, request.url); const [, ...segments] = rewriteTarget.pathname.split("/"); const normalized = new URL(`/${segments.filter(Boolean).join("/")}`, request.url); normalized.search = rewriteTarget.search; return NextResponse.rewrite(normalized, { headers: response.headers });}export const config = { matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],};```> The file is `proxy.ts` (Next.js 16 convention), not `middleware.ts`.### Step 6: Internal hrefs — keep `next/link`Per the cache-components note above, **leave existing `next/link` imports alone**. Middleware redirects unprefixed URLs to the active locale on click. The only places to use the next-intl-aware Link are inside client components that explicitly need to switch locales (e.g. a locale switcher) — and even then, `usePathname()` + `useRouter().push()` from `next/navigation` plus a manual segment swap is often cleaner under cacheComponents.For programmatic redirects in server code, use `next/navigation`'s `redirect`:```tsredirect(`/${await getLocale()}/account/login`);```### Step 7: `lib/seo.ts` — locale-aware canonicals + hreflang alternates```tsimport { defaultLocale, enabledLocales } from "./i18n";import { getLocale } from "./params";function withLocalePath(locale: string, pathname: string): string { const normalized = normalizePath(pathname); return normalized === "/" ? `/${locale}` : `/${locale}${normalized}`;}export async function buildAlternates({ pathname, searchParams }: {...}): Promise<Metadata["alternates"]> { const locale = await getLocale(); const canonical = buildCanonicalPath(withLocalePath(locale, pathname), searchParams); const languages: Record<string, string> = {}; for (const candidate of enabledLocales) { languages[candidate] = buildCanonicalPath(withLocalePath(candidate, pathname), searchParams); } languages["x-default"] = buildCanonicalPath(withLocalePath(defaultLocale, pathname), searchParams); return { canonical, languages };}````buildAlternates` is now async — update every caller to `await`.### Step 8: Sitemap per-locale entriesEdit `app/sitemap/[shard]/route.ts`. For every resource, emit one `<url>` per enabled locale and add `<xhtml:link rel="alternate" hreflang="..." href="..." />` siblings inside each `<url>` pointing at the other locale variants. Add `xmlns:xhtml="http://www.w3.org/1999/xhtml"` to the `<urlset>` opening tag.```tsimport { enabledLocales } from "@/lib/i18n";function localizePath(locale: string, pathname: string): string { if (pathname === "/") return `/${locale}`; return `/${locale}${pathname.startsWith("/") ? pathname : `/${pathname}`}`;}// Inside renderShard(): for each item, for each locale, emit a <url> with// a <loc> at the localized path and an <xhtml:link> per other locale.````app/sitemap.xml/route.ts` (the index) doesn't need locale handling — it only lists shard URLs, which stay locale-agnostic.### Step 9: `next.config.ts` rewrites/redirects on `/:locale/*`Existing markdown content-negotiation rewrites must move their `source` from `/products/:handle` to `/:locale/products/:handle`, etc. Destinations stay at `/md/products/:handle`, `/md/collections/:handle`, and `/md/search` — the handlers read `locale` from query params, not the URL path. Add the locale-prefixed redirect rules from the original config (`/:locale/product*` → `/:locale/products*`).### Step 10: `app/(unlocalized)/page.tsx` fallback```tsimport { permanentRedirect } from "next/navigation";import { defaultLocale } from "@/lib/i18n";export default function UnlocalizedRoot(): never { permanentRedirect(`/${defaultLocale}`);}```This is a defensive fallback; with `localePrefix: "always"` middleware should already redirect `/`.### Step 11: `generateStaticParams` on the locale layout```tsimport { locales } from "@/lib/i18n";export const generateStaticParams = async () => { return locales.map((locale) => ({ locale }));};```### Step 12: Patch `instant` samplesWalk every route file that exports `instant` and add `locale` to each sample's `params`:```tsparams: { locale: "en-US", handle: "__placeholder__" }```If any layout-level server component (e.g. a shipping/postal banner, geo-aware nav) reads `headers()`, also add a `headers` array to every sample:```tsheaders: [["x-vercel-ip-postal-code", null]]```(See "Cache Components compatibility D/E" at the top.)### Step 13: (Conditional) Re-enable `LocaleCurrencySelector` in the megamenuOnly if the `enable-shopify-menus` skill has already been run and `components/nav/megamenu/index.tsx` exists. The selector component lives at `components/nav/locale-currency.tsx` (with a fallback at `locale-currency-fallback.tsx`). Wire it into both `MegamenuDesktop` and `MegamenuMobile` per the original instructions.## VerifyingAfter applying:```bashpnpm build # should pass; routes prerender at /en-US, /en-GB, etc.pnpm dev # then:curl -I / # → 307 /en-UScurl -I /products # → 307 /en-US/productscurl /sitemap.xml # → sitemapindex listing shardscurl /sitemap/products-1.xml # → entries with /en-US/... URLs + xhtml:link alternatescurl /en-US # → 200 with <html lang="en-US">```Smoke-test checklist:- [ ] Build passes- [ ] Bare `/` redirects to default locale- [ ] Each enabled locale serves 200 at its prefix- [ ] `<html lang>` matches the URL's locale segment- [ ] Sitemap emits one entry per locale per page- [ ] Canonical + hreflang alternates appear in page metadata- [ ] Unprefixed internal links from existing `next/link` calls redirect (one extra hop, but correct)
Vercel and Shopify are rebuilding Hydrogen
Vercel commits to being a design partner on the open source rebuild of Hydrogen, Shopify's framework for headless storefronts.









