enable-i18n
Kích hoạt i18n dựa trên next-intl trong mẫu cửa hàng — URL có tiền tố ngôn ngữ, danh mục tin nhắn theo từng ngôn ngữ và bộ chuyển đổi ngôn ngữ. Sử dụng khi người dùng muốn "ngôn ngữ…
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)