afaqrashid.com  ·  portfolio · 2026 ed.

Next.js + Shopify Storefront API: Fetching and Caching, Tested

A headless Shopify store on Next.js talks to Shopify through the Storefront API, which is GraphQL. Every query is a POST request to one URL, and that matters for caching in Next.js, because the request deduplication you get for free applies to GET requests. I wanted real numbers, so I counted the requests that actually left Next.js.

Setup: Next.js 16.4.4 with next build and next start, the App Router, and Shopify's public test store mock.shop, which answers Storefront API queries with no token. A small local proxy sat between Next.js and mock.shop and logged every request it forwarded.

The fetch helper

// lib/shopify.ts
const ENDPOINT = `https://${process.env.SHOPIFY_STORE_DOMAIN}/api/2026-10/graphql.json`;

export async function shopifyFetch<T>(
  query: string,
  variables: Record<string, unknown> = {},
  init: RequestInit & { next?: { revalidate?: number; tags?: string[] } } = {},
): 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 }),
    ...init,
  });
  const json = await res.json();
  if (json.errors) throw new Error(JSON.stringify(json.errors));
  return json.data as T;
}

For testing, point ENDPOINT at https://mock.shop/api and drop the token header. A product query looks like this:

query Product($handle: String!) {
  product(handle: $handle) {
    title
    description
    priceRange { minVariantPrice { amount currencyCode } }
  }
}

Note that GraphQL errors come back with status 200 and an errors array, so checking res.ok is not enough.

The result

A product page that loads the product in generateMetadata (for the title) and again in the page. Requests to the Storefront API per page view, on a dynamic route:

                                   1st view   2nd view
plain fetch (POST)                     2          2
wrapped in React cache()               1          1
fetch with next: { revalidate: 60 }    1          0

No caching: two requests every view

With the plain helper, every view of /product/slides sent two identical POST requests: one from generateMetadata and one from the page. Next.js did not deduplicate them, because they are POST requests. A product page with a header menu, a product query and a recommendations query multiplies this, and every request to Shopify adds time before the page can render.

React cache(): one request per view

import { cache } from "react";

const getProduct = cache((handle: string) =>
  shopifyFetch<ProductQuery>(PRODUCT, { handle }),
);

// use getProduct(handle) in both generateMetadata and the page

This brought it to one request per view. cache() only lasts for one render, so the next visitor triggers a new request. That is what you want for anything that must be live, such as stock levels or a customer's cart.

revalidate: one request, then none

shopifyFetch<ProductQuery>(PRODUCT, { handle }, {
  next: { revalidate: 60, tags: ["product"] },
});

Adding next.revalidate to the POST request changed it the most. The first view sent one request, shared by generateMetadata and the page. The second view sent none. A different product sent its own request, because the cache key includes the request body, so each handle and each set of variables is cached separately. The responses were written to .next/cache/fetch-cache and survived a restart of next start: after a restart, a view that was already cached sent no request.

This is the right default for product, collection and content queries, which change rarely compared with how often they are read. Keep it off cart and customer queries.

At build time it looks different

With generateStaticParams for two products, the build called the helper seven times (static params, two pages and two metadata calls, plus two listing pages) and only four requests reached the Storefront API. So a statically built store can look fine in testing, and then send twice the requests once a route becomes dynamic, for example after adding searchParams or cookies. Count requests on the route as it runs in production, not only during the build.

What I use

  • Catalogue queries (products, collections, menus): next.revalidate with a tag per type, so a Shopify webhook can clear them when a product changes.
  • Anything per visitor (cart, customer, live inventory): no shared cache, wrapped in React cache() so one render asks once.
  • One helper for every query, so the caching choice is made in one place and never by accident.

On PolarPro, a headless Shopify store I worked on for several years, the biggest speed risk was the marketing scripts rather than the API calls; I wrote about that in Shopify theme vs headless vs Hydrogen. Getting the data layer right first makes those problems easier to see.

Related

Sources