enable-i18n
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…
npx skills add https://github.com/vercel/shop --skill enable-i18nEnable 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-marketsinstead 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 message loaders/catalogs; everything downstream (routing, sitemap, alternates, switcher) reads from it. Do not add a locale-to-currency map; commerce currency comes from Shopify responses when Markets is enabled.
// lib/i18n/index.ts
export 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 on
lib/i18n/routing.tsandlib/i18n/navigation.ts(next-intl)- Route segment
app/[locale]/containing every page proxy.tsmiddleware runningnext-intl/middlewarelib/params.tsgetLocale()reading fromnext/root-paramslib/i18n/request.tsloading messages by resolved locale- Locale-prefixed canonicals + hreflang alternates in
lib/seo.ts - Sitemap entries per locale
next.config.tsrewrites/redirects on/:locale/*sourcesapp/(unlocalized)/page.tsxfallback redirect to default localegenerateStaticParamson the root layout- (If
enable-shopify-menusalready ran) Re-enableLocaleCurrencySelectorin the megamenu
Cache Components compatibility — read this first
The 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 used
next-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:
export 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:
unstable_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
// BREAKS: TS doesn't narrow `session` after redirect
import { 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:
import { 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 config
Create lib/i18n/routing.ts:
import { 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:
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);
Per "Cache Components compatibility C" above,
Linkhere is mostly used by the locale switcher / programmatic routing in client components — not as a wholesale replacement fornext/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 originalapp/layout.tsxafter the move — see compatibility A above; both files cannot coexist.app/page.tsx,app/error.tsx,app/not-found.tsx→app/[locale]/...app/account/,app/cart/,app/collections/,app/pages/,app/policies/,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
import { 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
import { 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
import 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), notmiddleware.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:
redirect(`/${await getLocale()}/account/login`);
Step 7: lib/seo.ts — locale-aware canonicals + hreflang alternates
import { 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 entries
Edit 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.
import { 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
import { 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
import { locales } from "@/lib/i18n";
export const generateStaticParams = async () => {
return locales.map((locale) => ({ locale }));
};
Step 12: Patch instant samples
Walk every route file that exports instant and add locale to each sample's params:
params: { 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:
headers: [["x-vercel-ip-postal-code", null]];
(See "Cache Components compatibility D/E" at the top.)
Step 13: (Conditional) Re-enable LocaleCurrencySelector in the megamenu
Only 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.
Verifying
After applying:
pnpm build # should pass; routes prerender at /en-US, /en-GB, etc.
pnpm dev # then:
curl -I / # → 307 /en-US
curl -I /products # → 307 /en-US/products
curl /sitemap.xml # → sitemapindex listing shards
curl /sitemap/products-1.xml # → entries with /en-US/... URLs + xhtml:link alternates
curl /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/linkcalls redirect (one extra hop, but correct)