Ezgi Hocaoğlu

Ezgi Hocaoğlu 👋

Frontend developer 🖥️ building e-commerce front-ends with Shopify, React & WordPress — 5 years of custom storefronts, widgets and checkout flows.

Ezgi Hocaoğlu — CV

PDF · one page

Preview not loading? Open the file in a new tab instead.

Currently on repeat

DISCOUNT

Hostinger

See discounted plans
Headless article cover
Headless
  • 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.

Filed under Headless
All articles