Hydrogen made headless storefronts easy to ship, but not portable. At Vercel Ship 26 in New York, we announced that we are working with Shopify to rebuild it from the ground up, a shared bet on a more open web.

The new version is open source and runtime agnostic, meaning it can run anywhere JavaScript does. You can build with Svelte, Nuxt, Next.js or even bring your own custom framework.
Our strategy includes three layers: core, client and server.
Link to headingThe core
Core is the JavaScript we all used to write for the Shopify API, and never shared. Now it lives in one place.
Take formatMoney. The open web already solved most of this with Intl.NumberFormat.
const price = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD",}).format(19.99); // "$19.99"But the Shopify API doesn't hand you a number. It responds with a custom type, MoneyV2, and the amount is a signed decimal number serialized as a string.
import { formatMoney, type MoneyV2 } from "@shopify/hydrogen";
export function formatPrice(money: MoneyV2, locale = "en-US") { return formatMoney(money, { locale }).toString(); // $19.99}The result is the same, but you’re not writing or maintaining the glue code anymore. When the API changes, the upgrade is trivial.
Centralize the core and you fix each bug once, ship improvements to everyone, and get back to building.
Link to headingThe client
Rendering what the core returns involves the same repeated decisions. Cart state is the obvious one.
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
Anyone who's built a commerce app has written a version of this. Different code every time, all chasing the same things.
With Hydrogen, the client layer now handles the cart. State management becomes one import.
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
Centralize this and you get the best practices for free, so you can spend your time on the parts that are actually yours to build. It's available for React today on the Hydrogen preview branch, with more frameworks coming.
Link to headingThe server
Developers need full-stack access to build storefronts that scale without sacrificing performance. Static content should serve instantly from a CDN while dynamic data like inventory streams in.
The open-source community solved this with frameworks like Next.js, Nuxt, and SvelteKit: full-stack capabilities with no lock-in to a proprietary runtime.
Say your storefront caches product queries with on-demand revalidation. You write the GraphQL query. Hydrogen gives you a type-safe client. Next.js handles caching, and you get full-stack frameworks plus the headless Shopify API with none of the glue code.
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 demandexport 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}Shopify already supports these frameworks through its Headless sales channel, but until now we’ve each written our own bindings to the same API contract. At this layer, the fix is guidance, not more code. Humans and agents both need to know how to use what these frameworks already do, instead of reinventing it for Shopify.
That guidance ships as documentation, templates, and skills.
---name: enable-i18ndescription: > 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.tsexport 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 page3. `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 locale6. Locale-prefixed canonicals + hreflang alternates in `lib/seo.ts`7. Sitemap entries per locale8. `next.config.ts` rewrites/redirects on `/:locale/*` sources9. `app/(unlocalized)/page.tsx` fallback redirect to default locale10. `generateStaticParams` on the root layout11. (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:
```tsexport 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:
```tsunstable_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 redirectimport { 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:
```tsimport { 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`:
```tsimport { 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`:
```tsimport { 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
```tsimport { 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
```tsimport { 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
```tsimport 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`:
```tsredirect(`/${await getLocale()}/account/login`);```
### Step 7: `lib/seo.ts` — locale-aware canonicals + hreflang alternates
```tsimport { 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.
```tsimport { 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
```tsimport { 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
```tsimport { 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`:
```tsparams: { 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:
```tsheaders: [["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:
```bashpnpm build # should pass; routes prerender at /en-US, /en-GB, etc.pnpm dev # then:curl -I / # → 307 /en-UScurl -I /products # → 307 /en-US/productscurl /sitemap.xml # → sitemapindex listing shardscurl /sitemap/products-1.xml # → entries with /en-US/... URLs + xhtml:link alternatescurl /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
Link to headingHow it relates to vercel.shop
Before Hydrogen, we built vercel.shop, our own template for agentic commerce. It makes going headless with Next.js and Shopify easier.
It worked, and now we're going further. We'll fold everything we learned building vercel.shop into Hydrogen at the client and server layers. Once Hydrogen is stable, vercel.shop adopts it and becomes our reference for building storefronts with Hydrogen and Next.js.
Link to headingGoing forward
Vercel’s goal is simple: build a better web, for everyone.
With Hydrogen, that means the best developer experience without locking you into a runtime, framework, or platform.
We're building this in the open. Follow along on GitHub: try it, fork it, and help shape what comes next.