NetcaneTechnologies

Shopify

Headless Shopify + Next.js: Storefront API Integration

Fetch products and collections from Shopify Storefront API in Next.js with typed queries, cart handling, and performance patterns.

Yasir Haleem4 min read

A headless Shopify storefront on Next.js gives you full control over UX, Core Web Vitals, and merchandising—while Shopify handles inventory, checkout, and payments. The Storefront API (GraphQL) is the integration surface. This article covers client setup, product queries, cart creation, and the decisions that keep the stack production-ready.

Storefront API vs Admin API

  • Storefront API — public-facing reads, cart mutations, checkout URLs. Safe to use from the server; expose only the public storefront token via server actions or route handlers, not in client bundles unless you accept token rotation discipline.
  • Admin API — orders, products admin, webhooks. Server-only, private app credentials.

For a marketing + commerce site, almost all page rendering uses Storefront API. Admin API is for sync jobs, webhooks, or internal tools.

GraphQL client

// lib/shopify.ts
const domain = process.env.SHOPIFY_STORE_DOMAIN!;
const storefrontToken = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
const apiVersion = process.env.SHOPIFY_API_VERSION ?? "2024-10";
 
export async function shopifyFetch<T>({
  query,
  variables,
  cache = "force-cache",
  tags,
}: {
  query: string;
  variables?: Record<string, unknown>;
  cache?: RequestCache;
  tags?: string[];
}): Promise<T> {
  const res = await fetch(`https://${domain}/api/${apiVersion}/graphql.json`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Shopify-Storefront-Access-Token": storefrontToken,
    },
    body: JSON.stringify({ query, variables }),
    cache,
    next: tags ? { tags } : undefined,
  });
 
  const json = await res.json();
  if (json.errors) {
    throw new Error(json.errors[0]?.message ?? "Shopify GraphQL error");
  }
  return json.data;
}

Centralizing fetch here makes it easy to add logging, retry on 429, and swap cache strategy per route.

Product by handle

Handles are stable URLs (/products/classic-tee). Query by handle, not numeric ID, in the storefront.

# lib/shopify-queries.ts
export const PRODUCT_BY_HANDLE = `
  query ProductByHandle($handle: String!) {
    product(handle: $handle) {
      id
      title
      description
      handle
      availableForSale
      priceRange {
        minVariantPrice { amount currencyCode }
      }
      images(first: 8) {
        edges {
          node {
            url
            altText
            width
            height
          }
        }
      }
      variants(first: 20) {
        edges {
          node {
            id
            title
            availableForSale
            selectedOptions { name value }
            price { amount currencyCode }
          }
        }
      }
    }
  }
`;
// lib/shopify-products.ts
import { shopifyFetch } from "./shopify";
import { PRODUCT_BY_HANDLE } from "./shopify-queries";
 
type ProductByHandleData = {
  product: {
    id: string;
    title: string;
    handle: string;
    // ...narrow in production
  } | null;
};
 
export async function getProduct(handle: string) {
  const data = await shopifyFetch<ProductByHandleData>({
    query: PRODUCT_BY_HANDLE,
    variables: { handle },
    tags: [`product:${handle}`],
  });
  return data.product;
}

Map GraphQL edges/node to flat arrays in a mapper so React components do not repeat connection boilerplate.

Collection pages and pagination

Collections use collection(handle:) with products(first: 24, after: $cursor) for cursor pagination. Generate static params for top collections at build time; use dynamicParams = true for long tails.

For SEO, each product and collection page needs unique generateMetadata from Shopify seo fields when you enable them in the admin, or fall back to title and description.

Cart and checkout

Headless flow:

  1. cartCreate mutation → store cart.id in cookie
  2. cartLinesAdd / cartLinesUpdate for mutations
  3. Redirect to cart.checkoutUrl for Shopify-hosted checkout

Cart mutations belong in Server Actions or API routes so the storefront token stays off the client if you prefer. Alternatively, use Shopify’s Hydrogen cart utilities if you adopt that stack—same API underneath.

mutation CartCreate {
  cartCreate {
    cart {
      id
      checkoutUrl
    }
  }
}

Do not rebuild checkout—Shopify’s hosted checkout handles tax, shipping, and PCI. Your job is fast product discovery and a frictionless path to checkoutUrl.

Performance

  • Use next/image with Shopify CDN URLs (already optimized; set sizes correctly on PLP grids).
  • Server-render product HTML for crawlers; avoid client-only product shells.
  • Tag revalidation: Shopify webhooks (products/update, collections/update) → /api/revalidate with product/collection tags.
  • Limit variant counts in PLP cards; fetch full variant matrix only on PDP.

Theme vs headless

Native Shopify themes are faster to launch for simple DTC stores. Headless wins when you need a custom design system, content from a separate CMS, or sub-second interactions on large catalogs. Budget for cart, webhooks, and SEO parity—not just product pages.

Summary

Headless Shopify on Next.js means a single Storefront GraphQL client, handle-based routing, server-side cart mutations, and checkout via checkoutUrl. Map connection types to plain arrays, cache with tags, revalidate on webhooks, and keep Admin API credentials off the storefront path.

About the author

Yasir Haleem is founder and lead engineer at Netcane Technologies. He builds production Next.js sites with headless CMS platforms — Strapi, Contentful, Sanity, and WordPress — with a focus on performance, SEO, and maintainable architecture.

Let's work together

Tell us about your project. We respond within one business day with a clear scope, timeline, and estimate.