- 10 min read
Building a Headless Shopify Storefront with the Next.js App Router
Going headless is a trade. You get full control of the front-end and a build pipeline that is yours, and you take on everything Shopify used to do for free: routing, caching, SEO, image handling, and the parts of the storefront nobody thinks about until they are missing.
It pays off when the front-end is genuinely the product — a custom configurator, a content-led store, a design the theme architecture keeps fighting. It does not pay off because the theme feels old.
One place for Storefront API access
Everything goes through a single server-side fetch wrapper. The token never reaches the browser, and every call gets consistent caching and error handling:
const endpoint = `https://${process.env.SHOPIFY_STORE_DOMAIN}/api/2025-07/graphql.json`;
export async function storefront<T>(
query: string,
variables: Record<string, unknown> = {},
{ tags = [], revalidate = 3600 }: { tags?: string[]; revalidate?: number } = {}
): Promise<T> {
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Storefront-Access-Token": process.env.SHOPIFY_STOREFRONT_TOKEN!,
},
body: JSON.stringify({ query, variables }),
next: { revalidate, tags },
});
if (!res.ok) throw new Error(`Storefront API ${res.status}`);
const { data, errors } = await res.json();
if (errors?.length) throw new Error(errors.map((e: any) => e.message).join("; "));
return data as T;
}
A product page then reads as an ordinary server component — no client-side data fetching, no loading spinner, no token in the bundle:
export default async function ProductPage({ params }: { params: { handle: string } }) {
const { product } = await storefront<{ product: Product }>(PRODUCT_QUERY,
{ handle: params.handle },
{ tags: [`product:${params.handle}`] }
);
if (!product) notFound();
return (
<article>
<ProductGallery images={product.images.nodes} />
<h1>{product.title}</h1>
<AddToCart variantId={product.variants.nodes[0].id} />
</article>
);
}
Caching is the whole game
Tagging every fetch is what lets you cache aggressively and still be correct. A product update webhook hits a route handler, which invalidates exactly the pages that changed:
export async function POST(request: Request) {
if (!verifyShopifyWebhook(request)) return new Response("Unauthorized", { status: 401 });
const { handle } = await request.json();
revalidateTag(`product:${handle}`);
revalidateTag("collection:all");
return Response.json({ revalidated: true });
}
Without tags you end up choosing between stale prices and rendering every page on every request. Neither is acceptable in a store.
The cart belongs on the server
Keep the cart ID in an httpOnly cookie and mutate it from Server Actions. The
browser never holds a token, and the cart survives a refresh, a new tab, and a slow phone:
"use server";
export async function addToCart(variantId: string, quantity = 1) {
const cartId = cookies().get("cartId")?.value ?? (await createCart());
await storefront(CART_LINES_ADD, {
cartId,
lines: [{ merchandiseId: variantId, quantity }],
}, { revalidate: 0 });
revalidateTag("cart");
}
What you give up
- Checkout stays Shopify's. You hand off to it. Plan the handoff, including how the cart cookie and analytics survive the jump.
- Theme app extensions stop working. Every app that injected itself into the theme now needs a custom integration or a replacement.
- SEO is your problem. Canonicals, structured data, sitemaps, redirects from the old URLs — all of it moves to you.
- Merchants lose the theme editor. Give them something back, usually metaobjects, or you become the content team.
Decide those four before writing the first component. They are what makes a headless build succeed or quietly become a maintenance burden.