Back

Connecting Next.js 16 to the Shopify Storefront API: A Technical Walkthrough

MM
MVPHub
12 min read

Connecting Next.js 16 to the Shopify Storefront API: A Technical Walkthrough

The Shopify Storefront API is how you build a headless frontend on top of a Shopify store. It's a GraphQL API (with a limited REST fallback) that gives you read access to products, collections, and customer data, plus mutations for managing carts and handing off to hosted checkout.

This guide walks through the specific integration patterns you need to get a production-ready Next.js 16 App Router storefront talking to a real Shopify store. Code snippets are adapted from real MVPHub template implementations — not tutorial fluff.


TL;DR

  • Storefront API is GraphQL. You query for products/collections via /api/2026-01/graphql.json.
  • Authenticate with a public access token from a Shopify custom app. It's safe to ship to the browser.
  • Use server components for catalog queries. Use client components only for the cart and checkout UI.
  • Carts live in Shopify. You store the cartId in an HTTP-only cookie and pass it to every cart mutation.
  • Checkout happens on Shopify's domain. You redirect to cart.checkoutUrl when the customer is ready to pay.
  • The API is versioned. Pin your app to a specific version and upgrade quarterly.

Step 1: Get credentials from Shopify

In the Shopify admin:

  1. Go to Settings → Apps and sales channels → Develop apps
  2. Click Create an app, name it "Storefront Client" or similar
  3. Go to Configuration → Storefront API integration → Configure
  4. Grant the scopes you need. At minimum: unauthenticated_read_product_listings, unauthenticated_read_product_inventory, unauthenticated_write_checkouts, unauthenticated_write_customers
  5. Save, install the app on your store
  6. Copy the Storefront API access token — it starts with shpat_...

Security note: the Storefront API access token is designed to be public. Unlike Admin API tokens, you can embed it in browser code without consequences — it only grants the scopes you explicitly checked, and those scopes are all read-only for public catalog data or write-only for carts.


Step 2: Environment variables

In your Next.js project:

# .env.local
NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN=yourstore.myshopify.com
NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN=shpat_...
NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-01

The NEXT_PUBLIC_ prefix makes them available in browser code, which is fine for the Storefront API but never do this for Admin API tokens.


Step 3: Build a typed client

Don't use a full GraphQL client library for this — it's overkill. A small fetch wrapper is all you need:

// src/lib/shopify/client.ts
const SHOPIFY_ENDPOINT = `https://${process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN}/api/${process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION}/graphql.json`

const STOREFRONT_TOKEN = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN!

export async function shopifyFetch<T>({
  query,
  variables,
  cache = 'force-cache',
  revalidate = 600,
}: {
  query: string
  variables?: Record<string, unknown>
  cache?: RequestCache
  revalidate?: number
}): Promise<T> {
  const res = await fetch(SHOPIFY_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Shopify-Storefront-Access-Token': STOREFRONT_TOKEN,
    },
    body: JSON.stringify({ query, variables }),
    cache,
    next: { revalidate },
  })

  if (!res.ok) {
    throw new Error(`Shopify API error: ${res.status}`)
  }

  const json = await res.json() as { data?: T; errors?: Array<{ message: string }> }

  if (json.errors?.length) {
    throw new Error(`Shopify GraphQL error: ${json.errors[0].message}`)
  }

  return json.data as T
}

Three things to notice:

  1. ISR-friendly defaults. cache: 'force-cache' + revalidate: 600 means product data gets cached at the edge for 10 minutes. Override it when you need fresher data.
  2. Error handling is explicit. GraphQL returns 200 on errors, so you have to check json.errors yourself.
  3. Single fetch wrapper. Every caller goes through this function, which makes it easy to add logging, retries, and rate-limit handling in one place later.

Step 4: Query products

Product queries are server components in Next.js App Router. They SSR or ISR the response into HTML, which is exactly what you want for SEO.

// src/app/products/[handle]/page.tsx
import { shopifyFetch } from '@/lib/shopify/client'

const PRODUCT_QUERY = /* GraphQL */ `
  query Product($handle: String!) {
    product(handle: $handle) {
      id
      handle
      title
      description
      descriptionHtml
      vendor
      productType
      tags
      availableForSale
      priceRange {
        minVariantPrice { amount currencyCode }
        maxVariantPrice { amount currencyCode }
      }
      images(first: 10) {
        edges {
          node { url altText width height }
        }
      }
      variants(first: 100) {
        edges {
          node {
            id
            title
            availableForSale
            quantityAvailable
            price { amount currencyCode }
            selectedOptions { name value }
          }
        }
      }
      seo { title description }
    }
  }
`

interface ProductResponse {
  product: {
    id: string
    handle: string
    title: string
    description: string
    descriptionHtml: string
    // ... full type elided for brevity
  } | null
}

export const revalidate = 600 // ISR every 10 minutes

export default async function ProductPage({
  params,
}: {
  params: Promise<{ handle: string }>
}) {
  const { handle } = await params
  const { product } = await shopifyFetch<ProductResponse>({
    query: PRODUCT_QUERY,
    variables: { handle },
  })

  if (!product) return notFound()

  return (
    <div>
      <h1>{product.title}</h1>
      {/* Product UI */}
    </div>
  )
}

Key patterns:

  • Server component by default. No 'use client' at the top — the whole page renders on the server.
  • ISR via export const revalidate = 600. Product data refreshes every 10 minutes without running on every request.
  • GraphQL tagged with /* GraphQL */ — a convention that enables syntax highlighting in editors and works with codegen tools.

Querying a collection uses the same pattern with a collectionByHandle query.


Step 5: Build the cart

Carts in Shopify Storefront API are server-side objects. You create one when the customer adds their first item, store the cart ID in a cookie, and pass it to every mutation.

// src/lib/shopify/cart.ts
import { cookies } from 'next/headers'
import { shopifyFetch } from './client'

const CART_COOKIE = 'shopify_cart_id'

export async function getOrCreateCart(): Promise<string> {
  const cookieStore = await cookies()
  const existing = cookieStore.get(CART_COOKIE)?.value
  if (existing) return existing

  const { cartCreate } = await shopifyFetch<{
    cartCreate: { cart: { id: string } }
  }>({
    query: /* GraphQL */ `
      mutation CartCreate {
        cartCreate {
          cart { id }
        }
      }
    `,
    cache: 'no-store',
  })

  const cartId = cartCreate.cart.id
  cookieStore.set(CART_COOKIE, cartId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 30, // 30 days
  })

  return cartId
}

Details that matter:

  • HTTP-only cookie. The cart ID lives server-side only. The browser can't read it directly.
  • secure: true in production. Only transmitted over HTTPS.
  • sameSite: 'lax' allows the cookie to survive OAuth-style redirects from Stripe back to your domain (which is how Shopify checkout works).
  • 30-day expiry. Abandoned carts survive for a month, matching typical ecommerce recovery flows.

Adding items to cart

const ADD_TO_CART_MUTATION = /* GraphQL */ `
  mutation CartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
    cartLinesAdd(cartId: $cartId, lines: $lines) {
      cart {
        id
        totalQuantity
        lines(first: 100) {
          edges {
            node {
              id
              quantity
              merchandise {
                ... on ProductVariant {
                  id
                  title
                  product { title handle }
                  price { amount currencyCode }
                  image { url altText }
                }
              }
            }
          }
        }
        cost {
          subtotalAmount { amount currencyCode }
          totalAmount { amount currencyCode }
        }
      }
      userErrors { field message }
    }
  }
`

export async function addToCart(variantId: string, quantity: number) {
  const cartId = await getOrCreateCart()
  const result = await shopifyFetch<{
    cartLinesAdd: { cart: CartShape; userErrors: Array<{ message: string }> }
  }>({
    query: ADD_TO_CART_MUTATION,
    variables: {
      cartId,
      lines: [{ merchandiseId: variantId, quantity }],
    },
    cache: 'no-store',
  })

  if (result.cartLinesAdd.userErrors.length) {
    throw new Error(result.cartLinesAdd.userErrors[0].message)
  }

  return result.cartLinesAdd.cart
}

This is typically called from a Server Action or a route handler in your Next.js app — not directly from a client component, because it needs access to cookies.

Reading the cart

const CART_QUERY = /* GraphQL */ `
  query Cart($cartId: ID!) {
    cart(id: $cartId) {
      id
      totalQuantity
      checkoutUrl
      lines(first: 100) {
        edges {
          node {
            id
            quantity
            merchandise {
              ... on ProductVariant {
                id
                title
                product { title handle }
                price { amount currencyCode }
                image { url }
              }
            }
          }
        }
      }
      cost {
        subtotalAmount { amount currencyCode }
        totalAmount { amount currencyCode }
      }
    }
  }
`

export async function getCart(): Promise<CartShape | null> {
  const cookieStore = await cookies()
  const cartId = cookieStore.get(CART_COOKIE)?.value
  if (!cartId) return null

  const { cart } = await shopifyFetch<{ cart: CartShape | null }>({
    query: CART_QUERY,
    variables: { cartId },
    cache: 'no-store',
  })

  // If the cart was deleted on Shopify's side, clear the cookie
  if (!cart) {
    cookieStore.delete(CART_COOKIE)
  }

  return cart
}

The important defensive pattern: if Shopify returns null for a cart ID, clear the cookie. Carts can expire or be manually deleted on Shopify's side, and silently succeeding with a stale cookie creates confusing bugs.


Step 6: Checkout handoff

Checkout in headless Shopify is brilliantly simple: you don't build it. You redirect to the cart's checkoutUrl and Shopify handles the rest.

// In your cart component, the checkout button:
export async function ProceedToCheckout() {
  const cart = await getCart()
  if (!cart) return null

  return (
    <a
      href={cart.checkoutUrl}
      className="rounded-lg bg-primary px-6 py-3 text-white"
    >
      Checkout
    </a>
  )
}

That's it. When the customer clicks the button:

  1. They land on checkout.shopify.com/...
  2. Shopify renders the checkout page (with your store's branding if configured)
  3. Payment happens on Shopify's domain — PCI compliance is Shopify's problem
  4. On success, the customer is redirected back to a page on your domain that you configure in Shopify admin (Settings → Checkout → Order processing)

Customization options:

  • Theme it with Shopify's checkout branding settings (Settings → Checkout → Customize)
  • Add custom code via Checkout UI Extensions (Shopify Plus only, for Checkout Extensibility)
  • Pass additional data by appending ?discount=SUMMER20&attributes[source]=homepage to checkoutUrl

Step 7: Version pinning

Shopify's Storefront API is versioned with a YYYY-MM format. Versions are released quarterly (January, April, July, October) and each one is supported for 12 months.

Your approach should be:

  1. Pin to a specific version in your env var: NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-01
  2. Schedule upgrades to the new version within 6 months of release — gives you time to test, doesn't leave you stranded on deprecated versions
  3. Test every upgrade in staging first. Breaking changes are rare but not zero. Shopify publishes a changelog for each version.
  4. Run your full Playwright test suite against the upgraded version before deploying to production.

Practical tip: write a lightweight smoke test that queries one product, creates a cart, adds a line item, and prints the checkout URL. Run it after every version upgrade. If it passes, you're probably safe; if it fails, the error will tell you exactly what changed.


Step 8: Rate limiting and caching

The Storefront API has generous rate limits for the hosted cart operations (we're talking ~1000 requests/second on unauthenticated queries). For a typical store you will not hit them through organic traffic.

Where you might hit them:

  • Aggressive pre-warming of the ISR cache across many product pages
  • Automated testing that runs cart operations in a loop
  • Site migration scripts fetching full catalog snapshots

How to handle it:

  • The client should retry 429 responses with exponential backoff
  • For catalog warming, use the bulkOperationRunQuery mutation (Storefront API has a limited version) or rate-limit your migration script to 5-10 req/sec
  • Cache aggressively in Next.js — ISR with a 10-minute revalidation window means each product URL only hits Shopify 6 times per hour even at 1000 QPS

Common mistakes

Using the Admin API instead of Storefront API. The Admin API requires a secret token that must stay server-side. Don't confuse the two. Storefront API for customer-facing flows, Admin API for merchant-facing flows.

Storing full product objects in client state. You don't need to. Fetch products fresh from the server component on each request (ISR caches them anyway), and pass only the minimum data into client components.

Skipping userErrors in mutations. Shopify returns 200 even when a mutation fails. Always check userErrors in every mutation response.

Fetching the cart in every server component. Cache it per request with cache() from React:

import { cache } from 'react'
export const getCartCached = cache(getCart)

This dedupes multiple cart fetches within a single page render without introducing a real cache layer.

Forgetting to handle cart expiry. Shopify deletes abandoned carts after ~10 days of inactivity. Your code needs to notice and gracefully create a new one.

Not pinning the API version. Relying on Shopify's default can silently upgrade you to a new version on an unexpected day. Pin it explicitly.


MVPHub templates that implement this pattern

Every MVPHub Next.js + Shopify Headless template ships with the patterns above:


Next steps

Headless Shopify is a mature pattern. Pin your version, follow the patterns above, and the Storefront API becomes the boring foundation it should be — which leaves you free to spend engineering time on the parts of your store that actually matter.

Working products with full source code — live demo, one-time purchase, instant delivery.

Browse the marketplace
GR LIVEecommerce

Groover Multi-Purpose Store

$149

A fully-polished, multi-purpose e-commerce template engineered for brands that need the full feature set on day one — not a minimal starter you outgrow in a month. Groover ships with a live Medusa-backed catalog, category + collection merchandising, search with multi-facet filtering (category, collection, price, sale, stock, sort), product-detail with variant selection + image gallery + stock messaging + related products, Stripe Elements checkout with provider-aware setup panels, account dashboard with guest order lookup and authenticated order history, customer auth with login/register/logout/profile edit, wishlist with guest browser persistence and signed-in customer sync, blog list + detail, store directory, track-order page, branded 404, About/Contact/FAQ/Terms legal shell, GTM-friendly dataLayer wired into PDP/cards/wishlist/cart/checkout/search, locale + RTL foundation with persistent language switcher, PWA installability baseline, theme switching that applies before hydration and persists in both local storage and cookies, header active-route navigation with live mini-cart summary, skip-link / focus accessibility basics, app-level and route-level loading fallbacks, a recoverable error boundary, generated robots.txt and sitemap.xml, shared SEO metadata helpers, and a Playwright / Vitest / Lighthouse test harness. Every copy string lives in a typed content map so rebranding is a find-and-replace pass, not a code rewrite. Deploy it as-is or use it as the most complete starting point you can buy for a serious storefront.

★★★★★0 soldAstro · Medusa
FU LIVEecommerce

Furniture Store

$49

An elegant furniture and home furnishing e-commerce app with a design-forward Next.js storefront for SEO-optimized product pages and server-rendered category browsing. Alternative framework and mobile ports are available on demand. The visual design emphasizes large product imagery, room-based browsing, and material/color variant selection. Built with Radix UI, shadcn/ui, Tailwind CSS, and Framer Motion for a premium feel. Connects to any headless commerce backend — Medusa JS SDK integration is included. Form handling via React Hook Form with Zod validation ensures robust checkout and account flows. Great for furniture brands, interior design shops, or home decor marketplaces.

★★★★★0 soldExpo · Next.js
PE LIVEecommerce

Perfume Store

$49

A luxury-styled perfume and fragrance e-commerce app built for premium brand presentation. The ready-to-buy Next.js storefront features rich product pages with scent profiles, bottle size variants, gift set options, and server-rendered collections. Mobile, backend, and alternative framework ports are available on demand. The design uses shadcn/ui and Tailwind CSS with an elegant, minimalist aesthetic suited for luxury goods. Easy to customize — swap product data, update branding, and deploy. Perfect for perfume brands, fragrance boutiques, or niche scent marketplaces.

★★★★★0 soldMedusa · Expo

Keep reading — popular eCommerce guides on MVPHub.

All eCommerce articles

Explore other MVP verticals

MVPHub publishes templates and guides for ecommerce, SaaS, marketplaces, AI apps, booking platforms, subscription stores, directory sites, and more. Here are fresh picks from other verticals.