vercel/enable-shopify-markets
> Enable Shopify Markets with regional locales, localized Storefront API context, and next-intl routing. Supports locale-prefixed, invisible cookie-based, and per-domain routing without a separate market URL segment or market mapping.
npx skills add https://github.com/vercel/shop --skill enable-shopify-markets
Add multi-region commerce to the Vercel Shop template. A validated regional locale such as en-US, en-CA, or fr-CA is the complete market context: its language scopes translated content and its region scopes Shopify's country context. Do not create a separate market key, market-to-locale map, currency map, or /market/locale route.
Examples of valid public routing:
/fr-CA/products/shoe/products/shoe for every localeexample.ca/fr-CA/products/shoe or a single locale on example.fr/products/shoeNever generate redundant paths such as /ca/fr-CA/products/shoe.
Read the current versions of:
lib/i18n/index.ts, lib/i18n/request.ts, and lib/params.tsnext.config.ts and any existing proxy.tsThe template uses Next.js 16 with Cache Components. Read the installed Next.js routing/proxy docs and the installed next-intl routing types before applying examples from the web.
For any Shopify GraphQL edit, use Shopify AI Toolkit to confirm current API facts and validate the complete operation. Then follow shopify-graphql-reference for this template's placement, transforms, cache role, locale flow, and invalidation.
If the user has not already decided, ask which strategy they want:
/en-US/... and /fr-CA/..../products/... to /[locale]/products/... using the locale cookie.For sub-path routing, ask whether the default locale should be:
as-needed: /products/... for the default and /fr-CA/products/... for othersalways: /en-US/products/... and /fr-CA/products/...Then ask for the default locale and all enabled locales. Require regional BCP 47 tags with both language and region. For example:
en-US, en-CA, fr-CA, de-DE
Do not accept bare language tags such as en or fr for Markets mode. Do not ask for a separate market identifier or currency.
Before choosing invisible cookie routing, state its SEO tradeoff: every locale shares one canonical URL, so search engines and shared links cannot target a specific cookie-selected version. Use locale sub-paths or per-domain URLs when each localized version must be independently indexed.
Update lib/i18n/index.ts so the locale list is the only market configuration:
export const locales = ["en-US", "en-CA", "fr-CA"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en-US";
export const enabledLocales: readonly Locale[] = locales;
export const localeSwitchingEnabled = enabledLocales.length > 1;
export const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
Keep boundary validation through isEnabledLocale / resolveLocale. Derive Shopify context directly from the validated locale:
export function getCountryCode(locale: Locale): string {
return new Intl.Locale(locale).region ?? new Intl.Locale(defaultLocale).region ?? "US";
}
export function getLanguageCode(locale: Locale): string {
return new Intl.Locale(locale).language.toUpperCase();
}
Prefer Locale over string for internal locale parameters. Request bodies, cookies, headers, route params, and query params remain untrusted strings until validated.
Currency always comes from Shopify's localized response (MoneyV2.currencyCode, cart cost, product prices, etc.). Never infer currency from locale and never add localeCurrency, marketCurrency, or a locale-to-currency lookup.
When UI outside a price object needs a currency code, pass one from fetched Shopify data. If Shopify returns no product or cart from which to derive it, omit currency-specific UI rather than guessing.
Add a message loader and catalog for every enabled locale. Reuse a language catalog only intentionally; for example, en-US and en-CA may share en.json while Shopify still receives distinct country contexts.
Validate every generated JSON file after writing it. Keep all locale catalogs structurally aligned.
Create lib/i18n/routing.ts and use enabledLocales directly. There is no market mapping layer.
import { defineRouting } from "next-intl/routing";
import { defaultLocale, enabledLocales, LOCALE_COOKIE_NAME } from ".";
export const routing = defineRouting({
defaultLocale,
localeCookie: { name: LOCALE_COOKIE_NAME, sameSite: "lax" },
localePrefix: "as-needed", // or "always"
locales: enabledLocales,
});
Use full regional locale prefixes. One segment is enough.
import { defineRouting } from "next-intl/routing";
import { defaultLocale, enabledLocales, LOCALE_COOKIE_NAME } from ".";
export const routing = defineRouting({
alternateLinks: false,
defaultLocale,
localeCookie: { name: LOCALE_COOKIE_NAME, sameSite: "lax" },
localeDetection: true,
localePrefix: "never",
locales: enabledLocales,
});
localePrefix: "never" keeps the locale segment internal. On a request for /products/shoe, next-intl resolves the cookie (or first-visit language preference/default), then rewrites internally to a route such as /fr-CA/products/shoe. The browser URL stays /products/shoe.
Do not implement a second custom market rewrite on top of this. The internal [locale] segment is an implementation detail, not a public URL.
export const routing = defineRouting({
defaultLocale,
domains: [
{ defaultLocale: "en-US", domain: "example.com", locales: ["en-US"] },
{ defaultLocale: "en-CA", domain: "example.ca", locales: ["en-CA", "fr-CA"] },
],
localeCookie: { name: LOCALE_COOKIE_NAME, sameSite: "lax" },
localePrefix: "as-needed",
locales: enabledLocales,
});
The domain configuration is routing configuration, not a separate commerce market model. Shopify country and language still derive from the resolved regional locale.
Create client navigation exports only for components that explicitly switch locales:
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { usePathname, useRouter } = createNavigation(routing);
Do not replace every next/link import in the Server Component tree. Keep ordinary links request-independent under Cache Components.
app/[locale]/Move the root layout and all localized pages under app/[locale]/. The locale layout must be the root layout; do not leave app/layout.tsx above it.
Move:
app/layout.tsx to app/[locale]/layout.tsxapp/[locale]/...Keep these unlocalized at app/:
api/md/robots.tssitemap.xml/ and sitemap/globals.css, global-error.tsx, and static metadata filesUpdate typed route generics to include [locale], fix the moved globals.css import, and add locale values to every instant.unstable_samples[].params object.
Do not call setRequestLocale with Cache Components. Resolve locale through the root param so locale becomes an explicit route/cache input.
Update lib/params.ts:
import { notFound } from "next/navigation";
import { locale as rootLocale } from "next/root-params";
import { isEnabledLocale, type Locale } from "./i18n";
export async function getLocale(): Promise<Locale> {
const value = await rootLocale();
if (!value || !isEnabledLocale(value)) notFound();
return value;
}
Update lib/i18n/request.ts to call getLocale() and load the matching messages. Do not resolve locale by reading cookies or request headers from a cached component. The proxy owns request negotiation; React receives the validated internal route param.
Update the existing root proxy.ts to run next-intl for matched requests:
import createMiddleware from "next-intl/middleware";
import type { NextRequest, NextResponse } from "next/server";
import { routing } from "@/lib/i18n/routing";
const handleI18n = createMiddleware(routing);
export function proxy(request: NextRequest): NextResponse {
return handleI18n(request);
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|_next/data|_vercel|favicon.ico|robots.txt|sitemap.xml|.*\\..*).*)",
"/.well-known/:path*",
],
};
Preserve any other request handling already composed in proxy() and keep the template matcher unless the current checkout adds a route with different requirements. The matcher excludes framework internals and public files while keeping application routes and /.well-known/* in proxy processing.
For invisible cookie routing, direct public locale-prefixed URLs should canonicalize back to the clean path. next-intl's never mode handles this; do not expose the internal rewrite destination in links, metadata, or redirects.
Audit definitions and real callers. Every localized Storefront API operation must accept the validated Locale, derive country and language, and use Shopify's @inContext(country: $country, language: $language).
This includes:
enable-shopify-menusCached functions must receive locale explicitly. Never read the locale cookie, headers(), or cookies() inside a "use cache" function. The locale argument naturally separates cache entries; do not add a parallel market cache key.
Keep locale defaults only at compatibility boundaries where the base single-locale template needs them. Once a route has resolved locale, pass it explicitly rather than silently defaulting deeper in the stack.
Change getMenu({ handle }) to getMenu({ handle, locale }), add localized Storefront context to the validated query, and update every caller. Without this, navigation remains pinned to the default market.
Pass the active Locale into the Shopify/Hydrogen request context instead of using defaultLocale. Preserve locale across login, authorize, refresh, and logout return URLs. Validate any locale carried through OAuth state or URL params.
The chat route lives outside [locale], and invisible URLs do not reveal locale in the referer. Send the current locale explicitly in the client request payload, validate it in app/api/chat/route.ts, and put it in agent context. Do not infer it from URL segments or fall back unconditionally to defaultLocale.
Agent tools, Storefront MCP calls, product context, cart creation, and navigation outputs must use that validated locale.
After the proxy rewrite, localized page routes have an internal /:locale/... path even in invisible mode. Update content-negotiation rewrites so the locale reaches unlocalized app/md/... handlers as a validated query/header value. Preserve ?variant= and search parameters.
Switching language within the same country must not mutate buyer identity:
fr-CA to en-CA: set locale; no cart country updateen-CA to en-US: set locale and update buyer country to USKeep the mutation in a server action, validate both inputs, and update the cart's buyer identity directly — read the cart id from the shared cart cookie and issue cartBuyerIdentityUpdate through storefront.request:
"use server";
import { cookies } from "next/headers";
import { getCartIdFromCookie } from "@/lib/cart/server";
import { getCountryCode, isEnabledLocale, LOCALE_COOKIE_NAME } from "@/lib/i18n";
import { storefront } from "@/lib/shopify/storefront";
const BUYER_IDENTITY_MUTATION = /* GraphQL */ `
mutation cartBuyerIdentityUpdate($cartId: ID!, $buyerIdentity: CartBuyerIdentityInput!) {
cartBuyerIdentityUpdate(cartId: $cartId, buyerIdentity: $buyerIdentity) {
cart {
id
}
}
}
`;
export async function switchLocaleAction(currentValue: string, nextValue: string) {
if (!isEnabledLocale(currentValue) || !isEnabledLocale(nextValue)) {
return { error: "Unsupported locale", success: false } as const;
}
if (getCountryCode(currentValue) !== getCountryCode(nextValue)) {
const cartId = await getCartIdFromCookie();
if (cartId) {
await storefront.request(BUYER_IDENTITY_MUTATION, {
variables: {
buyerIdentity: { countryCode: getCountryCode(nextValue) },
cartId,
},
});
}
}
const cookieStore = await cookies();
cookieStore.set(LOCALE_COOKIE_NAME, nextValue, {
path: "/",
sameSite: "lax",
});
return { success: true } as const;
}
getCartIdFromCookie() (in lib/cart/server.ts) reads the shared cart cookie and returns the full gid://shopify/Cart/... id, so the action reuses the same format the Hydrogen handlers write.
The selector remains a leaf Client Component.
{ locale: nextLocale }.router.refresh(). The pathname must not change.Do not offer a separate currency selector unless the store has a Shopify-backed currency choice independent of country. Display currency from cart/product responses.
Each locale has a distinct indexable URL:
hreflang alternates for enabled locale URLs plus x-default./${locale}.All variants share one public URL:
hreflang URLs.alternateLinks: false in next-intl routing.Never put internal /[locale]/... rewrite targets into metadata or sitemap XML.
Run focused checks from apps/template:
pnpm codegen
pnpm lint
pnpm build
Then run the app and verify the selected strategy.
fr-CA to en-CA does not update buyer country.en-CA to en-US updates buyer country and invalidates cart cache./market/locale nesting.always or as-needed.curl -I http://localhost:3000/products/example
curl -I --cookie "NEXT_LOCALE=fr-CA" http://localhost:3000/products/example
/products/example as the public URL.<html lang="fr-CA"> and Shopify country CA context./fr-CA/products/example does not remain a public canonical URL.Take vercel/enable-shopify-markets from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.