enable-i18n

bởi vercel

Bậ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 thông điệp 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-i18n

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

  1. lib/i18n/routing.ts and lib/i18n/navigation.ts (next-intl)
  2. Route segment app/[locale]/ containing every page
  3. 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 locale
  6. Locale-prefixed canonicals + hreflang alternates in lib/seo.ts
  7. Sitemap entries per locale
  8. next.config.ts rewrites/redirects on /:locale/* sources
  9. app/(unlocalized)/page.tsx fallback redirect to default locale
  10. generateStaticParams on the root layout
  11. (If enable-shopify-menus already ran) Re-enable LocaleCurrencySelector in 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, 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.tsxapp/[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.tsxapp/[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: Extend proxy.ts

Compose next-intl after the existing Shopify route dispatch. handleShopifyRoutes() returns null synchronously when Hydrogen does not own the pathname, so check that result before locale routing without awaiting it:

const handleI18n = createMiddleware(routing);

// Keep the existing imports and add NextRequest as a runtime import.
export async function proxy(request: NextRequest): Promise<Response> {
  const requestContext = createCustomerRequestContext(request);
  const shopifyRoute = handleShopifyRoutes({
    // Preserve the template's handlers, session manager, and storefront client.
    request,
    requestContext,
  });
  if (shopifyRoute) return shopifyRoute;

  const i18nRequest = new NextRequest(request, {
    headers: requestContext.getForwardedRequestHeaders(),
  });
  const response = handleI18n(i18nRequest);
  requestContext.applyResponseHeaders(response.headers);
  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 });
}

Preserve the template's Shopify-owned API and protocol matchers, then add locale-prefixed Shopify endpoints now that locale routing is enabled:

export const config = {
  matcher: [
    // Keep every matcher already present in the template.
    "/:locale([a-zA-Z]{2}(?:-[a-zA-Z]{2})?)/agent/:action(handoff|buyer-claims).:format",
    "/:locale([a-zA-Z]{2}(?:-[a-zA-Z]{2})?)/cart.:format(js|json)",
    "/:locale([a-zA-Z]{2}(?:-[a-zA-Z]{2})?)/cart/:operation(add|update|change|clear).:format(js|json)",
  ],
};

Do not replace the explicit entries with /api/:path*: downstream applications must be able to add Route Handlers such as /api/webhooks or /api/custom without sending them through Shopify dispatch or locale middleware. If a new Hydrogen feature claims another reserved route, add that exact route family.

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:

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/link calls redirect (one extra hop, but correct)

Thêm skills từ vercel

vercel
vercel
API REST Vercel được mô phỏng để phát triển và kiểm thử cục bộ. Sử dụng khi người dùng cần tương tác với các điểm cuối API Vercel tại chỗ, kiểm thử tích hợp Vercel,…
emil-design-eng
vercel
Kỹ năng này mã hóa triết lý của Emil Kowalski về trau chuốt giao diện người dùng, thiết kế thành phần, quyết định hoạt ảnh và những chi tiết vô hình giúp phần mềm mang lại cảm giác tuyệt vời.
cron-jobs
vercel
Cấu hình và các phương pháp tốt nhất cho Vercel Cron Jobs. Sử dụng khi thêm, chỉnh sửa hoặc gỡ lỗi các tác vụ đã lên lịch trong vercel.json.
codegen
vercel
Các tiện ích tạo mã cho json-render. Sử dụng khi tạo mã từ đặc tả giao diện người dùng, xây dựng trình xuất mã tùy chỉnh, duyệt qua các đặc tả hoặc tuần tự hóa các thuộc tính cho…
agent-browser
vercel
Tự động hóa các tương tác trình duyệt để kiểm thử web, điền biểu mẫu, chụp ảnh màn hình và trích xuất dữ liệu. Sử dụng khi người dùng cần điều hướng trang web, tương tác với web…
ai-sdk
vercel
Trả lời các câu hỏi về AI SDK và giúp xây dựng các tính năng hỗ trợ bởi AI. Sử dụng khi nhà phát triển: (1) Hỏi về các chức năng của AI SDK như generateText, streamText,…
next-best-practice
vercel
Các phương pháp hay nhất của Next.js - quy ước tệp, ranh giới RSC, mẫu dữ liệu, API bất đồng bộ, siêu dữ liệu, xử lý lỗi, trình xử lý tuyến đường, tối ưu hóa hình ảnh/phông chữ,…
marketplace-add-ai
vercel
Thêm tích hợp AI Skills (Brand Review API) vào ứng dụng Sitecore Marketplace. Sử dụng khi người dùng muốn thêm phân tích nội dung hỗ trợ AI, đánh giá thương hiệu hoặc thương hiệu…