Skip to content
Dashboard

Vercel and Shopify are rebuilding Hydrogen

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
}

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

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

Link to headingHow it relates to vercel.shop

Link to headingGoing forward

Ready to deploy?