
Photo by Christiann Koepke on Unsplash
Connecting Next.js 16 to the Shopify Storefront API: A Technical Walkthrough
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
cartIdin an HTTP-only cookie and pass it to every cart mutation. - Checkout happens on Shopify's domain. You redirect to
cart.checkoutUrlwhen 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:
- Go to Settings → Apps and sales channels → Develop apps
- Click Create an app, name it "Storefront Client" or similar
- Go to Configuration → Storefront API integration → Configure
- Grant the scopes you need. At minimum:
unauthenticated_read_product_listings,unauthenticated_read_product_inventory,unauthenticated_write_checkouts,unauthenticated_write_customers - Save, install the app on your store
- 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:
- ISR-friendly defaults.
cache: 'force-cache'+revalidate: 600means product data gets cached at the edge for 10 minutes. Override it when you need fresher data. - Error handling is explicit. GraphQL returns 200 on errors, so you have to check
json.errorsyourself. - 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.
Cart creation and cookie handling
// 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: truein 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:
- They land on
checkout.shopify.com/... - Shopify renders the checkout page (with your store's branding if configured)
- Payment happens on Shopify's domain — PCI compliance is Shopify's problem
- 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]=homepagetocheckoutUrl
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:
- Pin to a specific version in your env var:
NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-01 - Schedule upgrades to the new version within 6 months of release — gives you time to test, doesn't leave you stranded on deprecated versions
- Test every upgrade in staging first. Breaking changes are rare but not zero. Shopify publishes a changelog for each version.
- 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
bulkOperationRunQuerymutation (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:
- Browse headless Shopify templates — Next.js 16 + Storefront API wired up
- Browse Shopify themes and headless builds — both flavors
- Every template includes TypeScript types, Playwright tests for cart and checkout, and documented version upgrade notes
Next steps
- Browse Next.js + Shopify headless templates
- Shopify Headless Template Guide — broader architecture and SEO context
- Best Next.js Ecommerce Templates in 2026 — architecture comparison
- Adding Stripe Checkout guide — for stores not using hosted Shopify checkout
- Compatibility matrix — backend and deployment support
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.







