Headless e-commerce separates your storefront from your backend, letting you build a fast, custom shopping experience while leveraging battle-tested commerce infrastructure for inventory, payments, and fulfillment. Next.js is the ideal frontend for headless commerce because server components fetch product data without exposing API keys, and static generation makes product pages load instantly. This guide builds a fully functional store with the Shopify Storefront API, a cart system, and Stripe-powered checkout.
Prerequisites
- →Node.js 20+
Required for Next.js development and build tooling.
- →Shopify Partner Account
Create a free Shopify development store with the Storefront API enabled to manage products and inventory.
- →Stripe Account
For processing payments at checkout. Free to create with test mode available.
- →TypeScript Fundamentals
Familiarity with TypeScript types, interfaces, and async/await patterns.
Scaffold the Next.js Project and Install Dependencies
Create a new Next.js application with the App Router and install the Shopify Storefront API client. The commerce project structure separates lib utilities, components, and app routes for clean architecture as the store grows.
npx create-next-app@latest my-store \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir
cd my-store
npm install @shopify/storefront-api-client
npm install zustand # For cart state managementTip: Zustand is a lightweight state manager ideal for cart state — it avoids the boilerplate of Context API for global state.
Tip: Create a .env.local file with SHOPIFY_STOREFRONT_ACCESS_TOKEN and SHOPIFY_STORE_DOMAIN.
Set Up the Shopify Storefront API Client
Create a typed API client that communicates with the Shopify Storefront API using GraphQL. The Storefront API is read-only for product data, which means your access token is safe to use in server components. Define reusable GraphQL fragments for products and variants.
// src/lib/shopify.ts
import { createStorefrontApiClient } from "@shopify/storefront-api-client";
const client = createStorefrontApiClient({
storeDomain: process.env.SHOPIFY_STORE_DOMAIN!,
apiVersion: "2025-01",
publicAccessToken: process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!,
});
const PRODUCT_FRAGMENT = `
fragment ProductFields on Product {
id
title
handle
description
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 4) {
edges {
node {
url
altText
width
height
}
}
}
variants(first: 10) {
edges {
node {
id
title
availableForSale
price {
amount
currencyCode
}
}
}
}
}
`;
export async function getProducts() {
const { data } = await client.request(
`query { products(first: 20) { edges { node { ...ProductFields } } } } ${PRODUCT_FRAGMENT}`
);
return data?.products.edges.map((edge: { node: unknown }) => edge.node) ?? [];
}
export async function getProductByHandle(handle: string) {
const { data } = await client.request(
`query($handle: String!) { product(handle: $handle) { ...ProductFields } } ${PRODUCT_FRAGMENT}`,
{ variables: { handle } }
);
return data?.product ?? null;
}Tip: Use GraphQL fragments to keep your product queries DRY across listing and detail pages.
Tip: Cache product data with Next.js fetch caching or unstable_cache for faster builds.
Build the Product Listing Page
Create a server component that fetches all products from Shopify and displays them in a responsive grid. Each product card shows the image, title, price, and a link to the detail page. Server components mean no loading spinners — the data is fetched and rendered on the server before the HTML reaches the browser.
// src/app/products/page.tsx
import Image from "next/image";
import Link from "next/link";
import { getProducts } from "@/lib/shopify";
export const metadata = {
title: "Products | My Store",
description: "Browse our collection of products.",
};
export default async function ProductsPage() {
const products = await getProducts();
return (
<main className="mx-auto max-w-7xl px-4 py-16">
<h1 className="mb-12 text-4xl font-bold">Products</h1>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product: any) => (
<Link
key={product.id}
href={`/products/${product.handle}`}
className="group rounded-lg border p-4 transition hover:shadow-lg"
>
{product.images.edges[0] && (
<Image
src={product.images.edges[0].node.url}
alt={product.images.edges[0].node.altText ?? product.title}
width={400}
height={400}
className="aspect-square w-full rounded object-cover"
/>
)}
<h2 className="mt-4 text-lg font-semibold group-hover:text-blue-600">
{product.title}
</h2>
<p className="mt-1 text-gray-600">
${product.priceRange.minVariantPrice.amount}
</p>
</Link>
))}
</div>
</main>
);
}Create the Product Detail Page with Variant Selection
Build a dynamic product page that displays images, description, variant options, and an add-to-cart button. The page uses generateStaticParams to pre-render every product at build time. Variant selection is handled by a client component that updates the selected variant and price.
// src/app/products/[handle]/page.tsx
import { getProducts, getProductByHandle } from "@/lib/shopify";
import { notFound } from "next/navigation";
import Image from "next/image";
import { AddToCart } from "@/components/AddToCart";
export async function generateStaticParams() {
const products = await getProducts();
return products.map((p: any) => ({ handle: p.handle }));
}
export async function generateMetadata({ params }: { params: { handle: string } }) {
const product = await getProductByHandle(params.handle);
if (!product) return {};
return {
title: `${product.title} | My Store`,
description: product.description?.slice(0, 160),
openGraph: {
images: product.images.edges[0]?.node.url ? [product.images.edges[0].node.url] : [],
},
};
}
export default async function ProductPage({ params }: { params: { handle: string } }) {
const product = await getProductByHandle(params.handle);
if (!product) notFound();
const variants = product.variants.edges.map((e: any) => e.node);
return (
<main className="mx-auto max-w-6xl px-4 py-16">
<div className="grid gap-12 md:grid-cols-2">
<div className="space-y-4">
{product.images.edges.map((edge: any, i: number) => (
<Image
key={i}
src={edge.node.url}
alt={edge.node.altText ?? product.title}
width={edge.node.width}
height={edge.node.height}
className="w-full rounded-lg"
priority={i === 0}
/>
))}
</div>
<div>
<h1 className="text-3xl font-bold">{product.title}</h1>
<p className="mt-2 text-2xl">
${product.priceRange.minVariantPrice.amount}
</p>
<p className="mt-4 text-gray-600">{product.description}</p>
<AddToCart variants={variants} />
</div>
</div>
</main>
);
}Tip: Use the priority prop on the first product image for LCP optimization.
Tip: Add JSON-LD Product schema for rich snippets in search results.
Implement the Cart with Zustand
Build a client-side cart using Zustand for state management with localStorage persistence. The cart store handles adding items, removing items, updating quantities, and calculating totals. Zustand's persist middleware ensures the cart survives page refreshes without a backend.
// src/lib/cart-store.ts
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface CartItem {
variantId: string;
title: string;
variantTitle: string;
price: number;
quantity: number;
image?: string;
}
interface CartStore {
items: CartItem[];
addItem: (item: Omit<CartItem, "quantity">) => void;
removeItem: (variantId: string) => void;
updateQuantity: (variantId: string, quantity: number) => void;
clearCart: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.variantId === item.variantId);
if (existing) {
return {
items: state.items.map((i) =>
i.variantId === item.variantId
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
removeItem: (variantId) =>
set((state) => ({
items: state.items.filter((i) => i.variantId !== variantId),
})),
updateQuantity: (variantId, quantity) =>
set((state) => ({
items: quantity <= 0
? state.items.filter((i) => i.variantId !== variantId)
: state.items.map((i) =>
i.variantId === variantId ? { ...i, quantity } : i
),
})),
clearCart: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{ name: "cart-storage" }
)
);Tip: Zustand's persist middleware uses localStorage by default — cart items survive page refreshes.
Tip: Use the 'total' method as a computed property rather than storing it to avoid sync issues.
Build the Checkout API with Stripe
Create a server-side API route that takes the cart items, creates line items for Stripe, and returns a Checkout Session URL. Stripe Checkout handles the entire payment form including address collection, tax calculation, and receipt emails. This keeps your app PCI-compliant without handling any card data.
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { items } = await req.json();
if (!items?.length) {
return NextResponse.json({ error: "Cart is empty" }, { status: 400 });
}
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
shipping_address_collection: {
allowed_countries: ["US", "CA", "GB", "AU"],
},
line_items: items.map((item: any) => ({
price_data: {
currency: "usd",
product_data: {
name: `${item.title} - ${item.variantTitle}`,
images: item.image ? [item.image] : [],
},
unit_amount: Math.round(item.price * 100),
},
quantity: item.quantity,
})),
success_url: `${process.env.NEXT_PUBLIC_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/cart`,
});
return NextResponse.json({ url: session.url });
}Tip: Always validate cart items on the server — never trust client-side prices.
Tip: Use Stripe's automatic tax calculation to handle sales tax across different jurisdictions.
Add Product Search and Filtering
Implement a search bar and category filters using URL search params and server components. By storing filter state in the URL, search results are shareable and work without JavaScript. The Shopify Storefront API supports full-text search and filtering by product type, vendor, and price range.
// src/components/SearchBar.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState, useTransition } from "react";
export function SearchBar() {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState(searchParams.get("q") ?? "");
function handleSearch(e: React.FormEvent) {
e.preventDefault();
startTransition(() => {
const params = new URLSearchParams(searchParams.toString());
if (query) {
params.set("q", query);
} else {
params.delete("q");
}
router.push(`/products?${params.toString()}`);
});
}
return (
<form onSubmit={handleSearch} className="mb-8">
<div className="flex gap-2">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
className="flex-1 rounded-lg border px-4 py-2"
/>
<button
type="submit"
className="rounded-lg bg-black px-6 py-2 text-white"
disabled={isPending}
>
{isPending ? "Searching..." : "Search"}
</button>
</div>
</form>
);
}Tip: Use useTransition to show a pending state during search navigation without blocking user input.
Tip: Debounce the search input for instant search — but use form submission for accessibility.
Deploy and Configure Production Settings
Deploy to Vercel with environment variables for Shopify and Stripe, configure webhooks for order fulfillment, and set up Incremental Static Regeneration to keep product data fresh. ISR lets you revalidate product pages on a schedule without rebuilding the entire site.
# Deploy to Vercel
npx vercel
# Set production environment variables
vercel env add SHOPIFY_STORE_DOMAIN
vercel env add SHOPIFY_STOREFRONT_ACCESS_TOKEN
vercel env add STRIPE_SECRET_KEY
vercel env add STRIPE_WEBHOOK_SECRET
vercel env add NEXT_PUBLIC_URL
# Deploy to production
vercel --prodTip: Add revalidate: 3600 to your product pages to refresh data every hour without a full rebuild.
Tip: Set up Shopify webhooks to trigger on-demand revalidation when products are updated.
Tip: Add Vercel Analytics to track conversion rates and Core Web Vitals for your store.
Next Steps
- →Add product reviews using a headless review platform like Judge.me or Stamped.
- →Implement wishlist functionality with Zustand and localStorage persistence.
- →Add email notifications for order confirmation and shipping updates with Resend.
- →Implement A/B testing on product pages to optimize conversion rates.