The checkout page is where revenue happens or dies. In headless WooCommerce, you’re building it from scratch — no default WooCommerce checkout template, no pre-built payment form, no automatic shipping calculation. This is both the biggest challenge and the biggest opportunity of going headless, and I want to be honest about the order of those two. The opportunity is real, but you earn it by rebuilding, correctly, a flow that WooCommerce spent fifteen years hardening. Go in respecting that and you will do fine.
The one framing I would fix in your head before writing a line of code: in a headless checkout, WooCommerce is the source of truth and your frontend is a well-behaved client. Every total, every tax line, every shipping rate is calculated server-side and read back. The moment your frontend starts computing money on its own — a “smarter” subtotal, a rounded tax — you have two sources of truth and you will spend a quarter reconciling them. Let the server own the numbers.
Checkout Architecture
A headless checkout typically has 3-4 steps, all communicating with WooCommerce via API:
Cart Review → Shipping Info → Payment → Confirmation
Store API WC REST API Stripe/ WC REST API
(cart) (shipping) PayPal JS (create order)
Cart Management with WooCommerce Store API
The WooCommerce Store API (included in WooCommerce 6.0+) provides cart endpoints designed for headless use:
// Cart state management (Next.js)
const STORE_API = ${process.env.WP_URL}/wp-json/wc/store/v1;
export async function getCart(nonce) {
const res = await fetch(${STORE_API}/cart, {
credentials: 'include',
headers: { 'Nonce': nonce }
});
return res.json();
}
export async function addToCart(productId, quantity, nonce) {
const res = await fetch(${STORE_API}/cart/add-item, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Nonce': nonce
},
body: JSON.stringify({ id: productId, quantity })
});
return res.json();
}
export async function applyCoupon(code, nonce) {
const res = await fetch(${STORE_API}/cart/apply-coupon, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Nonce': nonce
},
body: JSON.stringify({ code })
});
return res.json();
}
Important: The Store API uses a nonce for cart session management. Fetch it from the wc/store/v1/cart response header Nonce.
Shipping Calculation
WooCommerce calculates shipping server-side based on the customer’s address:
export async function updateShippingAddress(address, nonce) {
const res = await fetch(${STORE_API}/cart/update-customer, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Nonce': nonce
},
body: JSON.stringify({
shipping_address: {
first_name: address.firstName,
last_name: address.lastName,
address_1: address.address1,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country
}
})
});
const cart = await res.json();
// cart.shipping_rates contains available methods with costs
return cart;
}
The response includes calculated shipping rates that you display as options.
Payment Integration: Stripe
Stripe is the most common payment gateway for headless WooCommerce. Use Stripe Elements for PCI-compliant card collection:
import { loadStripe } from '@stripe/stripe-js';
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PK);
function CheckoutForm({ cart }) {
const stripe = useStripe();
const elements = useElements();
const handleSubmit = async (e) => {
e.preventDefault();
// 1. Create payment intent on your server
const { clientSecret } = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: cart.totals.total_price })
}).then(r => r.json());
// 2. Confirm payment with Stripe
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: elements.getElement(CardElement),
billing_details: { name: cart.billing_address.first_name }
}
});
if (error) {
setError(error.message);
return;
}
// 3. Create WooCommerce order
if (paymentIntent.status === 'succeeded') {
await createWooCommerceOrder(cart, paymentIntent.id);
}
};
return (
);
}
Creating the WooCommerce Order
After successful payment, create the order via WooCommerce REST API:
async function createWooCommerceOrder(cart, paymentIntentId) {
const orderData = {
payment_method: 'stripe',
payment_method_title: 'Credit Card (Stripe)',
set_paid: true,
transaction_id: paymentIntentId,
billing: cart.billing_address,
shipping: cart.shipping_address,
line_items: cart.items.map(item => ({
product_id: item.id,
variation_id: item.variation?.[0]?.attribute || undefined,
quantity: item.quantity
})),
shipping_lines: [{
method_id: cart.shipping_rates[0]?.rate_id,
method_title: cart.shipping_rates[0]?.name,
total: cart.shipping_rates[0]?.price
}],
coupon_lines: cart.coupons.map(c => ({ code: c.code })),
meta_data: [
{ key: '_stripe_payment_intent', value: paymentIntentId }
]
};
const response = await fetch(${WP_URL}/wp-json/wc/v3/orders, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Basic ${btoa(${CK}:${CS})}
},
body: JSON.stringify(orderData)
});
return response.json();
}
Checkout UX Best Practices
- Single-page checkout outperforms multi-step for most stores. Use accordion sections instead of separate pages.
- Address autocomplete via Google Places API reduces friction and errors.
- Real-time validation — validate fields on blur, not on submit.
- Guest checkout by default — don’t force account creation before purchase.
- Order summary always visible — on desktop, show cart summary in a sidebar.
- Express checkout buttons — Apple Pay, Google Pay, and PayPal above the fold.
Error Handling
Checkout errors lose customers. Handle every failure gracefully:
const ERROR_MESSAGES = {
'card_declined': 'Your card was declined. Please try another card.',
'insufficient_funds': 'Insufficient funds. Please try another payment method.',
'expired_card': 'Your card has expired. Please update your card details.',
'processing_error': 'A processing error occurred. Please try again.',
'stock_error': 'Some items in your cart are no longer available.',
'shipping_error': 'We cannot ship to this address. Please check your details.'
};
The bug that will actually cost you: state desync
Everything above is the happy path. The failure that keeps headless checkouts up at night is subtler than a declined card, and I recognize it from a previous life. In the Macromedia Director and Lingo years I built multi-user games — several clients, one shared world, each machine holding its own picture of the score. The entire genre of hard bug in that work was desync: the client was certain it knew the state, the server quietly disagreed, and somewhere between them a player got charged for a move that never landed. Twenty-five years later a headless checkout is the same problem wearing a suit. Your React app holds a cart it believes in; WooCommerce holds the cart of record; the network sits in the middle dropping and duplicating messages.
So treat the frontend cart as a cache of the server’s truth, never the truth itself. Re-read the cart from the Store API after every mutation instead of patching your local copy and hoping. Make order creation idempotent — a customer who double-clicks Pay, or whose phone retries on a flaky connection, must not create two orders and two charges; key the request so the second attempt returns the first order. And decide, deliberately, what happens when payment succeeds at Stripe but the WooCommerce order creation call fails: that money is real and the order does not exist yet. The answer is a server-side reconciliation job that matches payment intents to orders, not a hope that the front end will retry. Build that before launch, not after the first support ticket.
Conclusion
The headless checkout is the hardest part of a headless WooCommerce build, but it’s also where you have the most control over the customer experience. Use the WooCommerce Store API for cart management, Stripe Elements for PCI-compliant payments, and focus relentlessly on reducing friction. Every extra field, every unnecessary page load, every confusing error message costs conversions. Build lean, test with real users, and iterate.
Here is the one line I would leave you with: let the server own the money and the state, make every write idempotent, and reconcile payment to order on the back end where you can see it. Do those three things and headless checkout stops being the part of the project you dread and becomes the part you are proud of. Skip them and no amount of frontend polish will save the weekend you spend refunding duplicate charges.
Last modified: August 2, 2026
United States / English
Slovensko / Slovenčina
Canada / Français
Türkiye / Türkçe