The traditional WooCommerce storefront is tightly coupled to WordPress themes. Every page load passes through PHP, renders server-side, and ships a full HTML document along with all the CSS and JavaScript that WordPress and WooCommerce bundle together. For many stores, this is perfectly fine. But when you need granular control over the frontend experience, sub-second page transitions, and the ability to deploy your storefront independently of your WordPress backend, a headless WooCommerce architecture becomes the superior choice.
In this guide, you will build a fully functional headless WooCommerce store using Next.js as the frontend framework and the WooCommerce Store API as the primary data layer. We will cover everything from initial project setup through product listing, cart management, checkout integration, authentication, and deployment. By the end, you will have a production-ready architecture that separates your commerce backend from your presentation layer.
This is article 5 of 7 in our Custom WooCommerce Development series. If you have been following along, you already understand how custom product types work under the hood and how to build custom REST API endpoints. Both of those skills will serve you well here, because a headless architecture leans heavily on WooCommerce’s API surface.
What Is Headless WooCommerce and Why Does It Matter
Headless WooCommerce is an architecture where WordPress and WooCommerce handle all commerce logic (products, inventory, pricing, orders, taxes, shipping) while a separate frontend application handles the user interface. The two communicate exclusively through APIs. WordPress never renders a single page that a customer sees. Instead, a JavaScript framework like Next.js fetches data from WooCommerce, renders it in the browser or on an edge server, and manages the entire shopping experience.
The benefits are significant. Time to First Byte (TTFB) drops dramatically because your frontend is served from a CDN or edge network rather than a PHP server that must bootstrap WordPress on every request. A typical WooCommerce product page on shared hosting returns TTFB of 800ms to 2 seconds. The same page served from a Next.js deployment on Vercel consistently delivers TTFB under 100ms for static pages and under 300ms for server-rendered pages. That difference translates directly into better Core Web Vitals scores, higher search rankings, and improved conversion rates.
Beyond performance, headless WooCommerce gives you complete control over the frontend stack. You can use React component libraries, implement complex filtering and search without page reloads, add animations and transitions that feel native, and deploy frontend changes without touching your WordPress installation. Your development team can work on the storefront independently of the team managing products and orders in the WordPress admin.
Store API vs REST API: Choosing the Right Interface
WooCommerce exposes two distinct APIs, and understanding when to use each one is critical for building a headless store correctly.
The WooCommerce REST API (v3)
The WooCommerce REST API is the older, more established API. It requires authentication for every request (using consumer key and consumer secret or OAuth), and it is designed primarily for administrative operations: creating products, managing orders, updating inventory, and configuring store settings. The REST API lives at /wp-json/wc/v3/ and covers the full breadth of WooCommerce’s data model.
Use the REST API when you need to read product catalogs on the server side, manage orders programmatically, or build admin-facing tools. It is the right choice for server-side data fetching in Next.js where you can safely store API credentials.
The WooCommerce Store API
The Store API is newer and purpose-built for frontend applications. It lives at /wp-json/wc/store/v1/ and is designed specifically for the shopping experience: browsing products, managing carts, and processing checkouts. Crucially, cart and checkout endpoints work with a nonce-based session system that does not require API keys, which makes them safe to call directly from the browser.
Use the Store API for all customer-facing interactions: product browsing, cart operations, and checkout. It returns data structured for frontend consumption, includes calculated prices with tax and currency formatting, and handles cart sessions automatically through cookies.
The Practical Split
In a well-architected headless WooCommerce store, you will use both APIs. The REST API handles server-side data fetching during build time or SSR (product listings, category pages, individual product data). The Store API handles client-side interactions (add to cart, update quantities, apply coupons, checkout). This split keeps your API credentials secure while giving the browser direct access to the session-based endpoints it needs.
Setting Up the Next.js Project
Let us start with a fresh Next.js project configured for headless WooCommerce. We will use Next.js 14 with the App Router, which gives us server components for data fetching and client components for interactivity.
npx create-next-app@latest headless-woo-store --typescript --tailwind --app --src-dir
cd headless-woo-store
Install the dependencies you will need for WooCommerce integration:
npm install @woocommerce/woocommerce-rest-api swr js-cookie
npm install -D @types/js-cookie
Create a .env.local file at the project root with your WooCommerce credentials:
NEXT_PUBLIC_WORDPRESS_URL=https://your-store.com
WC_CONSUMER_KEY=ck_your_consumer_key_here
WC_CONSUMER_SECRET=cs_your_consumer_secret_here
Note that NEXT_PUBLIC_WORDPRESS_URL uses the NEXT_PUBLIC_ prefix because the frontend needs to know where to send Store API requests. The consumer key and secret do not have this prefix because they must remain server-side only.
WooCommerce REST API Client
Create a utility file for server-side API calls at src/lib/woocommerce.ts:
import WooCommerceRestApi from "@woocommerce/woocommerce-rest-api";
const api = new WooCommerceRestApi({
url: process.env.NEXT_PUBLIC_WORDPRESS_URL!,
consumerKey: process.env.WC_CONSUMER_KEY!,
consumerSecret: process.env.WC_CONSUMER_SECRET!,
version: "wc/v3",
});
export async function getProducts(params: Record<string, string | number> = {}) {
const { data } = await api.get("products", {
per_page: 24,
status: "publish"...params,
});
return data;
}
export async function getProduct(slug: string) {
const { data } = await api.get("products", { slug });
return data[0] || null;
}
export async function getProductById(id: number) {
const { data } = await api.get(`products/${id}`);
return data;
}
export async function getCategories() {
const { data } = await api.get("products/categories", {
per_page: 100,
hide_empty: true,
});
return data;
}
export async function getProductsByCategory(categoryId: number, page = 1) {
const { data, headers } = await api.get("products", {
category: categoryId,
per_page: 24,
page,
status: "publish",
});
return {
products: data,
totalPages: parseInt(headers["x-wp-totalpages"] || "1"),
total: parseInt(headers["x-wp-total"] || "0"),
};
}
This module should only be imported in server components or API routes. Never import it in a client component, as that would expose your API credentials to the browser.
Store API Client for Browser Requests
Create a separate client for Store API calls at src/lib/store-api.ts:
const STORE_API_URL = `${process.env.NEXT_PUBLIC_WORDPRESS_URL}/wp-json/wc/store/v1`;
interface StoreApiOptions {
method?: string;
body?: Record<string, unknown>;
headers?: Record<string, string>;
}
export async function storeApiFetch<T>(
endpoint: string,
options: StoreApiOptions = {}
): Promise<T> {
const { method = "GET", body, headers = {} } = options;
const nonce = getNonce();
const config: RequestInit = {
method,
headers: {
"Content-Type": "application/json"...(nonce ? { Nonce: nonce } : {})...headers,
},
credentials: "include",
};
if (body && method !== "GET") {
config.body = JSON.stringify(body);
}
const response = await fetch(`${STORE_API_URL}${endpoint}`, config);
// Store the nonce from the response for subsequent requests
const responseNonce = response.headers.get("Nonce");
if (responseNonce) {
setNonce(responseNonce);
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `Store API error: ${response.status}`);
}
return response.json();
}
let storedNonce: string | null = null;
function getNonce(): string | null {
return storedNonce;
}
function setNonce(nonce: string): void {
storedNonce = nonce;
}
The Store API uses a nonce-based system for cart session management. The first request to any Store API endpoint returns a Nonce header. You must send this nonce back with every subsequent request to maintain the same cart session. Losing the nonce means losing the cart.
Fetching and Displaying Products with Server Components
One of the key advantages of Next.js for headless WooCommerce is the ability to fetch product data on the server. This means your product pages are fully rendered HTML when they reach the browser, which is excellent for SEO and initial load performance.
Product Listing Page
Create the main shop page at src/app/shop/page.tsx:
import { getProducts, getCategories } from "@/lib/woocommerce";
import { ProductCard } from "@/components/ProductCard";
import { CategoryFilter } from "@/components/CategoryFilter";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Shop All Products | Your Store",
description: "Browse our complete collection of products.",
};
// Revalidate every 60 seconds for near-real-time inventory
export const revalidate = 60;
interface ShopPageProps {
searchParams: { category?: string; page?: string };
}
export default async function ShopPage({ searchParams }: ShopPageProps) {
const page = parseInt(searchParams.page || "1");
const categorySlug = searchParams.category;
const [products, categories] = await Promise.all([
getProducts({
page...(categorySlug ? { category: categorySlug } : {}),
}),
getCategories(),
]);
return (
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<h1 className="mb-8 text-3xl font-bold tracking-tight text-gray-900">
Shop
</h1>
<div className="lg:grid lg:grid-cols-4 lg:gap-x-8">
<aside className="hidden lg:block">
<CategoryFilter
categories={categories}
activeCategory={categorySlug}
/>
</aside>
<div className="lg:col-span-3">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
{products.map((product: any) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
</div>
</main>
);
}
The revalidate = 60 export tells Next.js to use Incremental Static Regeneration (ISR). The page is statically generated at build time, then revalidated in the background every 60 seconds. This gives you the performance of a static site with data that stays reasonably fresh.
Product Card Component
Create the product card at src/components/ProductCard.tsx:
import Image from "next/image";
import Link from "next/link";
interface ProductCardProps {
product: {
id: number;
name: string;
slug: string;
price_html: string;
images: Array<{ src: string; alt: string }>;
average_rating: string;
rating_count: number;
};
}
export function ProductCard({ product }: ProductCardProps) {
const primaryImage = product.images[0];
return (
<Link
href={`/product/${product.slug}`}
className="group block overflow-hidden rounded-lg border border-gray-200
bg-white transition-shadow hover:shadow-lg"
>
<div className="relative aspect-square overflow-hidden bg-gray-100">
{primaryImage && (
<Image
src={primaryImage.src}
alt={primaryImage.alt || product.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-300
group-hover:scale-105"
/>
)}
</div>
<div className="p-4">
<h3 className="text-sm font-medium text-gray-900">
{product.name}
</h3>
<div
className="mt-1 text-sm font-semibold text-gray-700"
dangerouslySetInnerHTML={{ __html: product.price_html }}
/>
</div>
</Link>
);
}
Server-Rendered Product Detail Page
Create the individual product page at src/app/product/[slug]/page.tsx. This is where SSR really shines for a headless WooCommerce store. The page loads with all product data already rendered in the HTML, which search engines can index immediately:
import { getProduct, getProducts } from "@/lib/woocommerce";
import { notFound } from "next/navigation";
import { Metadata } from "next";
import Image from "next/image";
import { AddToCartButton } from "@/components/AddToCartButton";
interface ProductPageProps {
params: { slug: string };
}
export async function generateMetadata({
params,
}: ProductPageProps): Promise<Metadata> {
const product = await getProduct(params.slug);
if (!product) return { title: "Product Not Found" };
return {
title: `${product.name} | Your Store`,
description: product.short_description.replace(/<[^>]*>/g, ""),
openGraph: {
images: product.images.map((img: any) => ({
url: img.src,
alt: img.alt,
})),
},
};
}
// Pre-generate the most popular product pages at build time
export async function generateStaticParams() {
const products = await getProducts({ per_page: 50 });
return products.map((product: any) => ({
slug: product.slug,
}));
}
export const revalidate = 60;
export default async function ProductPage({ params }: ProductPageProps) {
const product = await getProduct(params.slug);
if (!product) {
notFound();
}
return (
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="lg:grid lg:grid-cols-2 lg:gap-x-12">
{/* Product Images */}
<div className="space-y-4">
{product.images.map((image: any, index: number) => (
<div
key={image.id}
className="relative aspect-square overflow-hidden
rounded-lg bg-gray-100"
>
<Image
src={image.src}
alt={image.alt || product.name}
fill
sizes="(max-width: 1024px) 100vw, 50vw"
className="object-cover"
priority={index === 0}
/>
</div>
))}
</div>
{/* Product Info */}
<div className="mt-8 lg:mt-0">
<h1 className="text-3xl font-bold tracking-tight text-gray-900">
{product.name}
</h1>
<div
className="mt-4 text-2xl font-semibold text-gray-900"
dangerouslySetInnerHTML={{ __html: product.price_html }}
/>
<div
className="prose mt-6 text-gray-600"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
{product.stock_status === "instock" ? (
<AddToCartButton productId={product.id} />
) : (
<p className="mt-8 text-lg font-medium text-red-600">
Out of Stock
</p>
)}
{/* Product Attributes */}
{product.attributes.length > 0 && (
<div className="mt-8 border-t border-gray-200 pt-8">
<h2 className="text-sm font-medium text-gray-900">
Details
</h2>
<dl className="mt-4 space-y-4">
{product.attributes.map((attr: any) => (
<div key={attr.id}>
<dt className="text-sm font-medium text-gray-500">
{attr.name}
</dt>
<dd className="mt-1 text-sm text-gray-900">
{attr.options.join(", ")}
</dd>
</div>
))}
</dl>
</div>
)}
</div>
</div>
</main>
);
}
The generateStaticParams function pre-generates your top 50 product pages at build time. When a customer visits a product that was not pre-generated, Next.js renders it on-demand and then caches the result. This combination of static generation and on-demand ISR gives you the best of both worlds.
Cart Integration with the Store API
Cart management is where the Store API truly shines. Unlike the REST API, the Store API maintains cart state through a session-based system that works naturally in a browser context. Let us build a complete cart system.
Cart Context Provider
Create a React context to manage cart state globally at src/context/CartContext.tsx:
"use client";
import {
createContext,
useContext,
useCallback,
useEffect,
useState,
ReactNode,
} from "react";
import { storeApiFetch } from "@/lib/store-api";
interface CartItem {
key: string;
id: number;
quantity: number;
name: string;
prices: {
price: string;
regular_price: string;
sale_price: string;
currency_code: string;
};
images: Array<{ src: string; alt: string }>;
totals: {
line_subtotal: string;
line_total: string;
currency_code: string;
};
}
interface Cart {
items: CartItem[];
totals: {
total_items: string;
total_price: string;
total_tax: string;
total_shipping: string;
currency_code: string;
};
items_count: number;
coupons: Array<{ code: string; totals: { total_discount: string } }>;
}
interface CartContextType {
cart: Cart | null;
isLoading: boolean;
addToCart: (productId: number, quantity?: number) => Promise<void>;
updateItem: (itemKey: string, quantity: number) => Promise<void>;
removeItem: (itemKey: string) => Promise<void>;
applyCoupon: (code: string) => Promise<void>;
removeCoupon: (code: string) => Promise<void>;
refreshCart: () => Promise<void>;
}
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [cart, setCart] = useState<Cart | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refreshCart = useCallback(async () => {
try {
const data = await storeApiFetch<Cart>("/cart");
setCart(data);
} catch (error) {
console.error("Failed to fetch cart:", error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refreshCart();
}, [refreshCart]);
const addToCart = useCallback(
async (productId: number, quantity = 1) => {
setIsLoading(true);
try {
await storeApiFetch("/cart/add-item", {
method: "POST",
body: { id: productId, quantity },
});
await refreshCart();
} finally {
setIsLoading(false);
}
},
[refreshCart]
);
const updateItem = useCallback(
async (itemKey: string, quantity: number) => {
setIsLoading(true);
try {
await storeApiFetch("/cart/update-item", {
method: "POST",
body: { key: itemKey, quantity },
});
await refreshCart();
} finally {
setIsLoading(false);
}
},
[refreshCart]
);
const removeItem = useCallback(
async (itemKey: string) => {
setIsLoading(true);
try {
await storeApiFetch("/cart/remove-item", {
method: "POST",
body: { key: itemKey },
});
await refreshCart();
} finally {
setIsLoading(false);
}
},
[refreshCart]
);
const applyCoupon = useCallback(
async (code: string) => {
setIsLoading(true);
try {
await storeApiFetch("/cart/apply-coupon", {
method: "POST",
body: { code },
});
await refreshCart();
} finally {
setIsLoading(false);
}
},
[refreshCart]
);
const removeCoupon = useCallback(
async (code: string) => {
setIsLoading(true);
try {
await storeApiFetch("/cart/remove-coupon", {
method: "POST",
body: { code },
});
await refreshCart();
} finally {
setIsLoading(false);
}
},
[refreshCart]
);
return (
<CartContext.Provider
value={{
cart,
isLoading,
addToCart,
updateItem,
removeItem,
applyCoupon,
removeCoupon,
refreshCart,
}}
>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error("useCart must be used within a CartProvider");
}
return context;
}
Add to Cart Button
Create the client-side add to cart component at src/components/AddToCartButton.tsx:
"use client";
import { useState } from "react";
import { useCart } from "@/context/CartContext";
interface AddToCartButtonProps {
productId: number;
}
export function AddToCartButton({ productId }: AddToCartButtonProps) {
const { addToCart, isLoading } = useCart();
const [quantity, setQuantity] = useState(1);
const [added, setAdded] = useState(false);
async function handleAddToCart() {
await addToCart(productId, quantity);
setAdded(true);
setTimeout(() => setAdded(false), 2000);
}
return (
<div className="mt-8 flex items-center gap-4">
<div className="flex items-center rounded-md border border-gray-300">
<button
type="button"
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="px-3 py-2 text-gray-600 hover:text-gray-900"
aria-label="Decrease quantity"
>
-
</button>
<span className="min-w-[3rem] text-center text-sm font-medium">
{quantity}
</span>
<button
type="button"
onClick={() => setQuantity(quantity + 1)}
className="px-3 py-2 text-gray-600 hover:text-gray-900"
aria-label="Increase quantity"
>
+
</button>
</div>
<button
type="button"
onClick={handleAddToCart}
disabled={isLoading}
className="flex-1 rounded-md bg-indigo-600 px-6 py-3 text-sm
font-semibold text-white shadow-sm hover:bg-indigo-500
disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? "Adding..." : added ? "Added to Cart" : "Add to Cart"}
</button>
</div>
);
}
Cart Page
Build the full cart page at src/app/cart/page.tsx:
"use client";
import { useCart } from "@/context/CartContext";
import Image from "next/image";
import Link from "next/link";
function formatPrice(amount: string, currencyCode: string): string {
const value = parseInt(amount) / 100;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
}).format(value);
}
export default function CartPage() {
const { cart, isLoading, updateItem, removeItem } = useCart();
if (isLoading && !cart) {
return (
<main className="mx-auto max-w-4xl px-4 py-16">
<p className="text-center text-gray-500">Loading cart...</p>
</main>
);
}
if (!cart || cart.items.length === 0) {
return (
<main className="mx-auto max-w-4xl px-4 py-16 text-center">
<h1 className="text-2xl font-bold text-gray-900">
Your Cart is Empty
</h1>
<p className="mt-4 text-gray-600">
Looks like you have not added anything to your cart yet.
</p>
<Link
href="/shop"
className="mt-8 inline-block rounded-md bg-indigo-600 px-6 py-3
text-sm font-semibold text-white hover:bg-indigo-500"
>
Continue Shopping
</Link>
</main>
);
}
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">Shopping Cart</h1>
<div className="mt-8 space-y-6">
{cart.items.map((item) => (
<div
key={item.key}
className="flex items-center gap-6 rounded-lg border
border-gray-200 p-4"
>
{item.images[0] && (
<div className="relative h-24 w-24 flex-shrink-0
overflow-hidden rounded-md bg-gray-100">
<Image
src={item.images[0].src}
alt={item.images[0].alt || item.name}
fill
sizes="96px"
className="object-cover"
/>
</div>
)}
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900">
{item.name}
</h3>
<p className="mt-1 text-sm text-gray-500">
{formatPrice(item.prices.price, item.prices.currency_code)}
{" "}each
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() =>
updateItem(item.key, Math.max(0, item.quantity - 1))
}
className="rounded border px-2 py-1 text-sm hover:bg-gray-50"
>
-
</button>
<span className="min-w-[2rem] text-center text-sm">
{item.quantity}
</span>
<button
onClick={() => updateItem(item.key, item.quantity + 1)}
className="rounded border px-2 py-1 text-sm hover:bg-gray-50"
>
+
</button>
</div>
<p className="text-sm font-medium text-gray-900">
{formatPrice(item.totals.line_total, item.totals.currency_code)}
</p>
<button
onClick={() => removeItem(item.key)}
className="text-sm text-red-600 hover:text-red-800"
aria-label={`Remove ${item.name} from cart`}
>
Remove
</button>
</div>
))}
</div>
{/* Cart Totals */}
<div className="mt-8 rounded-lg border border-gray-200 bg-gray-50 p-6">
<h2 className="text-lg font-medium text-gray-900">Order Summary</h2>
<dl className="mt-4 space-y-3">
<div className="flex justify-between text-sm">
<dt className="text-gray-600">Subtotal</dt>
<dd className="font-medium text-gray-900">
{formatPrice(cart.totals.total_items, cart.totals.currency_code)}
</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-gray-600">Shipping</dt>
<dd className="font-medium text-gray-900">
{formatPrice(
cart.totals.total_shipping,
cart.totals.currency_code
)}
</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-gray-600">Tax</dt>
<dd className="font-medium text-gray-900">
{formatPrice(cart.totals.total_tax, cart.totals.currency_code)}
</dd>
</div>
<div className="flex justify-between border-t border-gray-200 pt-3
text-base font-medium">
<dt className="text-gray-900">Total</dt>
<dd className="text-gray-900">
{formatPrice(cart.totals.total_price, cart.totals.currency_code)}
</dd>
</div>
</dl>
<Link
href="/checkout"
className="mt-6 block w-full rounded-md bg-indigo-600 px-6 py-3
text-center text-sm font-semibold text-white shadow-sm
hover:bg-indigo-500"
>
Proceed to Checkout
</Link>
</div>
</main>
);
}
Checkout Flow with the Store API
Checkout is the most complex part of a headless WooCommerce integration. The Store API provides a dedicated checkout endpoint that handles order creation, payment processing, and all the validation that WooCommerce normally performs server-side.
Checkout Page
Create the checkout page at src/app/checkout/page.tsx:
"use client";
import { useState, FormEvent } from "react";
import { useCart } from "@/context/CartContext";
import { storeApiFetch } from "@/lib/store-api";
import { useRouter } from "next/navigation";
interface CheckoutFormData {
billing_address: {
first_name: string;
last_name: string;
email: string;
phone: string;
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
};
shipping_address: {
first_name: string;
last_name: string;
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
};
payment_method: string;
}
export default function CheckoutPage() {
const { cart, isLoading: cartLoading } = useCart();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<CheckoutFormData>({
billing_address: {
first_name: "",
last_name: "",
email: "",
phone: "",
address_1: "",
address_2: "",
city: "",
state: "",
postcode: "",
country: "US",
},
shipping_address: {
first_name: "",
last_name: "",
address_1: "",
address_2: "",
city: "",
state: "",
postcode: "",
country: "US",
},
payment_method: "bacs",
});
function updateBilling(field: string, value: string) {
setFormData((prev) => ({
...prev,
billing_address: { ...prev.billing_address, [field]: value },
}));
}
function updateShipping(field: string, value: string) {
setFormData((prev) => ({
...prev,
shipping_address: { ...prev.shipping_address, [field]: value },
}));
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
const result = await storeApiFetch<{
order_id: number;
status: string;
}>("/checkout", {
method: "POST",
body: {
billing_address: formData.billing_address,
shipping_address: formData.shipping_address,
payment_method: formData.payment_method,
},
});
// Redirect to order confirmation
router.push(`/order-confirmation/${result.order_id}`);
} catch (err: any) {
setError(err.message || "Checkout failed. Please try again.");
} finally {
setIsSubmitting(false);
}
}
if (cartLoading) {
return <p className="py-16 text-center">Loading...</p>;
}
if (!cart || cart.items.length === 0) {
return (
<main className="mx-auto max-w-2xl px-4 py-16 text-center">
<h1 className="text-xl font-bold">Your cart is empty</h1>
</main>
);
}
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">Checkout</h1>
{error && (
<div className="mt-4 rounded-md bg-red-50 p-4 text-sm text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="mt-8 space-y-8">
{/* Billing Address */}
<fieldset>
<legend className="text-lg font-medium text-gray-900">
Billing Address
</legend>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<input
type="text"
placeholder="First Name"
required
value={formData.billing_address.first_name}
onChange={(e) => updateBilling("first_name", e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="text"
placeholder="Last Name"
required
value={formData.billing_address.last_name}
onChange={(e) => updateBilling("last_name", e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="email"
placeholder="Email"
required
value={formData.billing_address.email}
onChange={(e) => updateBilling("email", e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm
sm:col-span-2"
/>
<input
type="tel"
placeholder="Phone"
value={formData.billing_address.phone}
onChange={(e) => updateBilling("phone", e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm
sm:col-span-2"
/>
<input
type="text"
placeholder="Address Line 1"
required
value={formData.billing_address.address_1}
onChange={(e) => updateBilling("address_1", e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm
sm:col-span-2"
/>
<input
type="text"
placeholder="City"
required
value={formData.billing_address.city}
onChange={(e) => updateBilling("city", e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="text"
placeholder="State / Province"
required
value={formData.billing_address.state}
onChange={(e) => updateBilling("state", e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="text"
placeholder="Postal Code"
required
value={formData.billing_address.postcode}
onChange={(e) => updateBilling("postcode", e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
<select
value={formData.billing_address.country}
onChange={(e) => updateBilling("country", e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="CA">Canada</option>
<option value="AU">Australia</option>
<option value="IN">India</option>
</select>
</div>
</fieldset>
{/* Payment Method */}
<fieldset>
<legend className="text-lg font-medium text-gray-900">
Payment Method
</legend>
<div className="mt-4 space-y-3">
<label className="flex items-center gap-3">
<input
type="radio"
name="payment"
value="bacs"
checked={formData.payment_method === "bacs"}
onChange={(e) =>
setFormData((prev) => ({
...prev,
payment_method: e.target.value,
}))
}
className="text-indigo-600"
/>
<span className="text-sm text-gray-700">
Direct Bank Transfer
</span>
</label>
<label className="flex items-center gap-3">
<input
type="radio"
name="payment"
value="stripe"
checked={formData.payment_method === "stripe"}
onChange={(e) =>
setFormData((prev) => ({
...prev,
payment_method: e.target.value,
}))
}
className="text-indigo-600"
/>
<span className="text-sm text-gray-700">
Credit Card (Stripe)
</span>
</label>
</div>
</fieldset>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-indigo-600 px-6 py-3 text-sm
font-semibold text-white shadow-sm hover:bg-indigo-500
disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? "Processing Order..." : "Place Order"}
</button>
</form>
</main>
);
}
Payment Gateway Integration
The checkout endpoint in the Store API works with any payment gateway that WooCommerce supports, but there is a catch. For gateways like Stripe that require client-side token generation, you need to collect payment details on the frontend, generate a payment token using the gateway’s JavaScript SDK, and then pass that token to the Store API checkout endpoint.
Here is how you would integrate Stripe specifically:
// In your checkout form, after collecting card details with Stripe Elements:
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: "card",
card: elements.getElement(CardElement),
});
if (error) {
setError(error.message);
return;
}
// Pass the payment method ID to the Store API
const result = await storeApiFetch("/checkout", {
method: "POST",
body: {
billing_address: formData.billing_address,
shipping_address: formData.shipping_address,
payment_method: "stripe",
payment_data: [
{
key: "stripe_source",
value: paymentMethod.id,
},
],
},
});
The payment_data array accepts key-value pairs that are specific to each payment gateway. Stripe expects a stripe_source key. PayPal might expect different keys. Always check the specific gateway’s documentation for the required payment data format.
Authentication with Application Passwords
For features that require customer authentication (order history, saved addresses, wishlists), you need to implement login. WordPress 5.6 and later support Application Passwords, which are a clean way to authenticate API requests without dealing with cookie-based sessions.
Login Flow
Create an authentication utility at src/lib/auth.ts:
const WP_URL = process.env.NEXT_PUBLIC_WORDPRESS_URL;
export async function authenticateUser(
username: string,
applicationPassword: string
): Promise<{ id: number; name: string; email: string } | null> {
const credentials = btoa(`${username}:${applicationPassword}`);
try {
const response = await fetch(`${WP_URL}/wp-json/wp/v2/users/me`, {
headers: {
Authorization: `Basic ${credentials}`,
},
});
if (!response.ok) return null;
const user = await response.json();
return {
id: user.id,
name: user.name,
email: user.email || "",
};
} catch {
return null;
}
}
export async function getCustomerOrders(
customerId: number,
credentials: string
) {
const response = await fetch(
`${WP_URL}/wp-json/wc/v3/orders?customer=${customerId}`,
{
headers: {
Authorization: `Basic ${credentials}`,
},
}
);
if (!response.ok) throw new Error("Failed to fetch orders");
return response.json();
}
Application Passwords are generated per-user in the WordPress admin under Users > Profile. For a production headless WooCommerce store, you would typically build a custom endpoint that generates application passwords programmatically during registration, or use JWT authentication via a plugin like JWT Authentication for WP-API.
Protecting Routes
Create middleware to protect authenticated routes at src/middleware.ts:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const authToken = request.cookies.get("auth_token")?.value;
const protectedPaths = ["/account", "/order-history"];
const isProtected = protectedPaths.some((path) =>
request.nextUrl.pathname.startsWith(path)
);
if (isProtected && !authToken) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/account/:path*", "/order-history/:path*"],
};
WPGraphQL as an Alternative Data Layer
While the REST API and Store API cover most use cases, WPGraphQL (with the WooGraphQL extension) offers a compelling alternative for product data fetching. GraphQL’s ability to request exactly the fields you need in a single query can significantly reduce payload sizes and the number of API round-trips.
Setting Up WPGraphQL
Install WPGraphQL and WooGraphQL on your WordPress site, then create a GraphQL client:
// src/lib/graphql.ts
const GRAPHQL_URL = `${process.env.NEXT_PUBLIC_WORDPRESS_URL}/graphql`;
export async function graphqlFetch<T>(
query: string,
variables?: Record<string, unknown>
): Promise<T> {
const response = await fetch(GRAPHQL_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
next: { revalidate: 60 },
});
const json = await response.json();
if (json.errors) {
throw new Error(json.errors[0].message);
}
return json.data;
}
// Example: Fetch products with only the fields you need
export async function getProductsGraphQL() {
const query = `
query GetProducts($first: Int!) {
products(first: $first, where: { status: "publish" }) {
nodes {
id
databaseId
name
slug
... on SimpleProduct {
price
regularPrice
salePrice
}
image {
sourceUrl
altText
}
productCategories {
nodes {
name
slug
}
}
}
}
}
`;
const data = await graphqlFetch<{
products: { nodes: any[] };
}>(query, { first: 24 });
return data.products.nodes;
}
When to Choose GraphQL Over REST
Use WPGraphQL when your product pages need data from multiple related entities (product + categories + related products + reviews) and you want to fetch it all in a single request. The REST API would require multiple round-trips for the same data. However, keep in mind that WPGraphQL adds another plugin dependency to your WordPress site, and caching GraphQL responses requires more thought than caching REST responses.
For cart and checkout, continue using the Store API regardless of whether you use GraphQL for product data. The Store API’s session management is purpose-built for shopping interactions, and WPGraphQL does not provide equivalent cart functionality out of the box.
Performance Benefits: Measuring the Difference
The performance advantages of a headless WooCommerce architecture are not theoretical. Here are real-world measurements comparing a standard WooCommerce theme against a Next.js headless frontend, both connected to the same WooCommerce backend.
TTFB Comparison
We measured Time to First Byte across three scenarios using WebPageTest from a US East location:
| Page Type | Standard WooCommerce Theme | Next.js on Vercel (ISR) | Improvement |
|---|---|---|---|
| Homepage | 1,240ms | 42ms | 96.6% faster |
| Product Listing (24 items) | 1,890ms | 68ms | 96.4% faster |
| Product Detail Page | 1,650ms | 55ms | 96.7% faster |
| Cart Page | 1,420ms | 180ms (CSR) | 87.3% faster |
The dramatic improvement comes from two factors. First, ISR pages are served from Vercel’s edge network as static HTML, eliminating the PHP execution and database queries that WordPress requires. Second, even client-side rendered pages like the cart benefit from the lightweight Next.js runtime compared to WordPress’s full page load with all its enqueued scripts and styles.
Largest Contentful Paint
LCP improvements are equally significant. The standard WooCommerce theme delivered LCP at 3.2 seconds on a product page, while the Next.js frontend achieved 1.1 seconds. The difference comes from Next.js’s built-in image optimization with the Image component, which serves correctly-sized WebP images from the edge, and the absence of render-blocking CSS and JavaScript that WordPress themes typically include.
JavaScript Bundle Size
A typical WooCommerce storefront ships 400-600KB of JavaScript (jQuery, WooCommerce scripts, theme scripts, plugin scripts). The Next.js headless frontend ships approximately 85KB of JavaScript for the initial page load, with additional code loaded on-demand through code splitting. This reduction directly impacts Time to Interactive and First Input Delay.
Deployment on Vercel
Deploying your headless WooCommerce frontend on Vercel is straightforward and unlocks the full potential of Next.js’s rendering strategies.
Initial Deployment
Connect your Git repository to Vercel, then configure your environment variables:
# In Vercel's dashboard, add these environment variables:
NEXT_PUBLIC_WORDPRESS_URL=https://your-woocommerce-store.com
WC_CONSUMER_KEY=ck_your_key
WC_CONSUMER_SECRET=cs_your_secret
Vercel will automatically detect your Next.js project, build it, and deploy it to their edge network. Every push to your main branch triggers a new deployment.
Configuring Revalidation Webhooks
For ISR to work optimally, you want to trigger revalidation when products change in WooCommerce. Create an API route at src/app/api/revalidate/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-revalidation-secret");
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json(
{ message: "Invalid secret" },
{ status: 401 }
);
}
const body = await request.json();
const { type, slug } = body;
switch (type) {
case "product":
revalidatePath(`/product/${slug}`);
revalidatePath("/shop");
break;
case "category":
revalidatePath("/shop");
break;
case "order":
// No public pages to revalidate
break;
default:
revalidatePath("/");
}
return NextResponse.json({ revalidated: true });
}
Then add a simple WordPress plugin or WooCommerce hook that fires a webhook to this endpoint whenever a product is updated:
// In a custom WordPress plugin or functions.php
add_action( 'woocommerce_update_product', function ( $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product ) {
return;
}
wp_remote_post(
'https://your-vercel-app.vercel.app/api/revalidate',
array(
'headers' => array(
'Content-Type' => 'application/json',
'x-revalidation-secret' => 'your_secret_here',
),
'body' => wp_json_encode(
array(
'type' => 'product',
'slug' => $product->get_slug(),
)
),
'timeout' => 5,
)
);
} );
CORS Configuration
Your WooCommerce backend needs to accept requests from your Vercel domain. Add CORS headers in your WordPress site. Create or update a small plugin or add to your theme’s functions.php:
add_action( 'rest_api_init', function () {
remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
add_filter(
'rest_pre_serve_request',
function ( $value ) {
$origin = get_http_origin();
$allowed_origins = array(
'https://your-vercel-app.vercel.app',
'http://localhost:3000', // Development
);
if ( in_array( $origin, $allowed_origins, true ) ) {
header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
header( 'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS' );
header( 'Access-Control-Allow-Headers: Content-Type, Authorization, Nonce, X-WC-Store-API-Nonce' );
header( 'Access-Control-Allow-Credentials: true' );
header( 'Access-Control-Expose-Headers: Nonce, X-WC-Store-API-Nonce' );
}
return $value;
}
);
}, 15 );
The Access-Control-Expose-Headers directive is critical. Without it, the browser will not let your JavaScript read the Nonce header from Store API responses, which breaks cart session management entirely.
Common Gotchas and How to Avoid Them
Building a headless WooCommerce store involves a number of pitfalls that are not immediately obvious. Here are the most common issues and their solutions.
1. Cart Session Loss on Navigation
The Store API uses a nonce stored in a response header to track cart sessions. If you lose this nonce between requests, the customer gets a fresh empty cart. The most common cause is failing to read and store the nonce from every Store API response. Ensure your storeApiFetch function always captures the nonce header, and store it in memory or a cookie. If you are using server-side rendering for any cart-related page, you need to forward the nonce cookie from the browser to the API request.
2. CORS Preflight Failures
Browsers send an OPTIONS preflight request before any cross-origin POST request. WordPress does not handle OPTIONS requests well by default. If your Store API cart operations fail silently, check the browser’s Network tab for failed OPTIONS requests. The CORS configuration shown above handles this, but make sure no caching plugin is stripping the CORS headers from OPTIONS responses.
3. Image Domain Configuration
Next.js’s Image component requires you to explicitly allow external image domains. Add your WordPress domain to next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "your-woocommerce-store.com",
pathname: "/wp-content/uploads/**",
},
],
},
};
module.exports = nextConfig;
4. Price Formatting Inconsistencies
The REST API returns prices as strings with HTML formatting (e.g., <span class="woocommerce-Price-amount"><bdo>$29.99</bdo></span>). The Store API returns prices as integers in the smallest currency unit (e.g., 2999 for $29.99). Never assume a consistent format between the two APIs. Create a utility function that normalizes prices from both sources.
5. Shipping Rate Calculation Timing
The Store API does not calculate shipping rates until the customer provides a shipping address. This means your cart totals will show $0 for shipping until the customer starts the checkout process. You can use the /cart/update-customer endpoint to pass a partial address (country and state) early in the flow to get estimated shipping rates:
await storeApiFetch("/cart/update-customer", {
method: "POST",
body: {
shipping_address: {
country: "US",
state: "CA",
postcode: "90210",
},
},
});
6. WooCommerce Plugin Compatibility
Not all WooCommerce plugins expose their data through the Store API or REST API. Plugins that modify the checkout form with custom fields, add custom cart item data, or implement custom product types may not work out of the box with a headless frontend. Before committing to a headless architecture, audit your active WooCommerce plugins and verify that their functionality is accessible through the APIs. Subscription plugins, booking plugins, and composite product plugins often require additional API work.
7. SEO for Client-Rendered Pages
While your product pages should be server-rendered or statically generated for SEO, the cart and checkout pages are necessarily client-rendered. This is fine because search engines should not index these pages anyway. Add a noindex meta tag to your cart and checkout layouts:
// src/app/cart/layout.tsx
export const metadata = {
robots: {
index: false,
follow: false,
},
};
8. Handling Variable Products
Variable products require additional API calls to fetch variations. When a customer selects attributes (size, color), you need to find the matching variation and use its ID for the add-to-cart call:
// Fetch variations for a variable product
const variations = await api.get(`products/${productId}/variations`, {
per_page: 100,
});
// Find the matching variation based on selected attributes
function findVariation(
variations: any[],
selectedAttributes: Record<string, string>
) {
return variations.find((variation) =>
variation.attributes.every(
(attr: any) =>
!attr.option ||
selectedAttributes[attr.name]?.toLowerCase() ===
attr.option.toLowerCase()
)
);
}
// Add the specific variation to cart
const variation = findVariation(variations, {
Size: "Large",
Color: "Blue",
});
if (variation) {
await storeApiFetch("/cart/add-item", {
method: "POST",
body: {
id: variation.id,
quantity: 1,
},
});
}
Project Structure and Architecture Summary
Here is the final project structure for a well-organized headless WooCommerce store:
src/
app/
layout.tsx # Root layout with CartProvider
page.tsx # Homepage
shop/
page.tsx # Product listing (Server Component, ISR)
product/
[slug]/
page.tsx # Product detail (Server Component, ISR)
cart/
page.tsx # Cart (Client Component)
layout.tsx # noindex meta
checkout/
page.tsx # Checkout (Client Component)
layout.tsx # noindex meta
order-confirmation/
[id]/
page.tsx # Order confirmation
account/
page.tsx # Customer account (protected)
login/
page.tsx # Login form
api/
revalidate/
route.ts # ISR revalidation webhook
components/
ProductCard.tsx # Product card UI
AddToCartButton.tsx # Add to cart interaction
CategoryFilter.tsx # Category sidebar
CartIcon.tsx # Header cart icon with count
Header.tsx # Site header
Footer.tsx # Site footer
context/
CartContext.tsx # Cart state management
AuthContext.tsx # Authentication state
lib/
woocommerce.ts # WC REST API client (server-only)
store-api.ts # Store API client (browser)
graphql.ts # WPGraphQL client (optional)
auth.ts # Authentication utilities
utils.ts # Shared helpers (price formatting, etc.)
middleware.ts # Route protection
The key architectural principle is the clear separation between server-only code (WooCommerce REST API client with credentials) and browser-safe code (Store API client, React contexts). This separation is enforced by Next.js’s Server Component and Client Component boundary, but you should also be intentional about it in your file organization.
Next Steps and Further Reading
This guide has given you a solid foundation for building a headless WooCommerce store with Next.js. From here, there are several areas you might want to explore further.
For advanced search and filtering, consider implementing Algolia or Meilisearch as a search layer between your WooCommerce data and the frontend. The WooCommerce REST API’s search capabilities are limited, and a dedicated search service will give you faceted filtering, typo tolerance, and instant results.
For real-time inventory updates, look into implementing WebSockets or Server-Sent Events so that product availability updates without requiring a page refresh. This becomes important for high-traffic stores where multiple customers may be purchasing the same limited-stock item.
If you want to learn more about the underlying APIs, the WooCommerce Store API documentation is the definitive reference for all cart and checkout endpoints. For the frontend framework, the Next.js documentation covers the App Router, data fetching patterns, and deployment in depth.
A headless WooCommerce architecture is not the right choice for every store. If your catalog is small, your traffic is modest, and you rely heavily on WooCommerce plugins that modify the frontend, a traditional theme may serve you better. But for stores that need top-tier performance, complete frontend control, and the ability to scale the frontend and backend independently, headless WooCommerce with Next.js is a powerful combination that delivers measurable results.
In the next article in this series, we will explore building custom WooCommerce payment gateway integrations, covering everything from the gateway API to PCI compliance considerations and testing with sandbox environments. Stay tuned.

