Skip to content
Dashboard

Vercel and Shopify are rebuilding Hydrogen

Shopify VP of Product Vanessa Lee on stage at Ship NYC with Vercel Chief Product Officer Tom Occhino
Shopify VP of Product Vanessa Lee on stage at Ship NYC with Vercel Chief Product Officer Tom Occhino

Copy link to headingThe core

const price = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(19.99); // "$19.99"

import { formatMoney, type MoneyV2 } from "@shopify/hydrogen";
export function formatPrice(money: MoneyV2, locale = "en-US") {
return formatMoney(money, { locale }).toString(); // $19.99
}

Copy link to headingThe client

cart.tsx
import { createContext, useContext, useState, useCallback } from "react";
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [cart, setCart] = useState(null);
const addLine = useCallback(async (variantId, quantity) => {
// custom code that we all wrote ourselves
}, []);
const updateLine = useCallback(async (variantId, quantity) => {
// custom code that we all wrote ourselves
}, []);
const removeLine = useCallback(async (variantId, quantity) => {
// custom code that we all wrote ourselves
}, []);
// applyDiscount, note, currency, error state, cross-tab sync, refetch on focus, etc.
return (
<CartContext.Provider value={{ cart, pending, addLine }}>
{children}
</CartContext.Provider>
);
}
export const useCart = () => useContext(CartContext);

Custom code for managing cart state with React

AddToCartButton.tsx
import { createCartComponents } from "@shopify/hydrogen/react";
const { useCartForm } = createCartComponents();
function AddToCartButton({ variantId }) {
const { formProps, register } = useCartForm();
return (
<form {...formProps()}>
<input
type="hidden"
{...register("merchandiseId", { value: variantId })}
/>
<button {...register("add")}>Add to cart</button>
</form>
);
}

With Hydrogen the cart state management is a single import

Copy link to headingThe server

queries.ts
import { PRODUCT_QUERY } from "@/lib/gql";
import { storefrontConfig } from "@/lib/config";
import { cacheTag } from 'next/cache'
import { createStorefrontClient } from "@shopify/hydrogen";
// A cacheable function for product data that can be revalidated on demand
export async function getProductData({ handle }) {
"use cache";
cacheTag(handle);
const client = createStorefrontClient({
type: "private_shared_rate_limit",
config: storefrontConfig,
});
const { data } = await client.graphql(PRODUCT_QUERY, {
variables: { handle },
});
return data
}

enable-i18n.mdx
---
name: enable-i18n
description: >
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.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:
```ts
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:
```ts
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`
```ts
// 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:
```ts
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`:
```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`:
```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.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
```ts
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
```ts
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
```ts
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), 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`:
```ts
redirect(`/${await getLocale()}/account/login`);
```
### Step 7: `lib/seo.ts` — locale-aware canonicals + hreflang alternates
```ts
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.
```ts
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
```ts
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
```ts
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`:
```ts
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:
```ts
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:
```bash
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)

A skill that teaches agents how to properly use i18n with Next.js App Router

Copy link to headingHow it relates to vercel.shop

Copy link to headingGoing forward

Ready to deploy?