A few weeks ago I tweeted that my go-to for e-commerce these days is WordPress and WooCommerce at the back, an Astro storefront at the front, and one Cloudflare Worker in the middle. The replies were mostly a polite version of “that sounds like three websites stapled together.” I get it. It does sound like that.
But I’ve now taken two real businesses live on it: a fashion label in Lagos, and more recently a food business up north. Real catalogues, real naira, real customers who ring the owner when something is off. So this is the long answer to the tweet. What the pieces are, why each one is there, what it costs, and the handful of things that caught me out. There’s a starter prompt at the end if you’d rather skip to building.
The problem every store has
An online store has two users, and they want different things.
The owner wants an admin they already understand. Add a product, change a price, upload a photo, look at today’s orders, create a coupon for the influencer who messaged on Instagram. They do not want to call you for any of that, and honestly you don’t want them to either.
The shopper wants the page to open. That’s it. On a phone, on mobile data, in the ten seconds before they lose interest and go back to WhatsApp.
A normal WooCommerce site tries to be both. It’s a PHP application rendering every page for every visitor, usually with a page builder and thirty plugins riding along. The admin side is excellent. The shopper side is slow in a way you can cache but never quite fix, because you’re fighting the architecture.
So I stopped asking one system to do both jobs.
- WooCommerce runs the back office. It lives on a subdomain shoppers never see.
- Astro builds the shop as plain HTML, once, at build time. Every product page, every category page.
- A Cloudflare Worker serves that HTML from the edge and handles the few things that must be live: cart, checkout, payment.
The owner keeps the admin they know. The shopper gets a site that opens like a landing page. And I get to write the storefront in a modern codebase instead of a theme’s functions.php.
The shape of it
yourshop.com - Cloudflare Worker
├── static assets - the Astro build (the entire storefront)
├── /api/store/* - proxied to the WooCommerce Store API (cart, checkout, shipping)
├── /api/pay/* - Paystack init + verify
├── /api/order - order summary for the confirmation page
└── /api/rebuild - WooCommerce webhook - triggers a rebuild
admin.yourshop.com - WordPress + WooCommerce
(products, orders, shipping zones, coupons, order emails)
How I set one up
This is the order I do it in now, and roughly why each step exists.
1. Put WordPress on cheap hosting, on a subdomain. admin. or office., whatever the owner finds natural. It will serve zero customer traffic, so it needs none of the things expensive WordPress hosting sells you. I install WooCommerce, set the currency to naira, turn on guest checkout, and create a REST API key for the storefront.
2. Load the catalogue into WooCommerce, not into code. This is a discipline thing. The temptation is to hard-code the first twenty products “for now”. Don’t. From day one the store is the source of truth, and the site reads from it, so the owner’s first edit works the same as their thousandth.
3. Set up shipping in WooCommerce too. Shipping zones live in WooCommerce - Settings - Shipping, exactly where the owner expects them: one zone per delivery area with a flat rate, plus “Local pickup” if they have a shop or kitchen customers can collect from. The storefront never hard-codes a fee. At checkout it asks the Store API for the rates that apply to the customer’s address, shows them, and the chosen rate goes onto the order. When delivery to Lekki goes up by ₦500, the owner changes one number and checkout follows.
4. Build the storefront in Astro and fetch the catalogue at build time. Astro calls the WooCommerce REST API, pulls every product, and renders a static page for each one.
// at build time, roughly:
const products = await wcFetch('/products?per_page=100&status=publish');
// - getStaticPaths() - one HTML page per product
The product page is now a file on Cloudflare’s edge. No database query, no cache to warm, no origin round trip. Google sees complete markup with real prices in the structured data. And if WordPress goes down at 2am, the shop stays up; only cart and checkout would notice.
5. Let WooCommerce be the cart. WooCommerce has a second API most people never look at, the Store API (wc/store/v1). It’s built for customers rather than admins: cart, shipping rates, checkout, all keyed by a Cart-Token header instead of secret keys. The Worker proxies it onto the shop’s own domain.
// /api/store/* - admin.yourshop.com/wp-json/wc/store/*
const upstream = await fetch(target, {
method: request.method,
headers, // forwards Cart-Token in
body: request.method === 'GET' ? undefined : request.body,
});
const newToken = upstream.headers.get('Cart-Token');
if (newToken) out.set('Cart-Token', newToken);
The browser keeps the token in localStorage and sends it with every cart call. No cookies crossing domains, no CORS fights, and the shopper never touches the WordPress domain. I didn’t write a cart. I wrote a UI for one that already existed.
6. Take payment on your own domain. Paystack has a WooCommerce plugin, and the lazy version of this stack sends the customer over to the WordPress domain to pay. I did that for about a day. You’ve built this fast, branded storefront, and then at the exact moment trust matters most, checkout teleports them to a page that looks like somebody else’s site.
So the Worker owns payment. Two endpoints. /api/pay/init takes the order ID after the Store API has placed the order, initialises a Paystack transaction server-side using the total from WooCommerce (never a number the browser sent), and returns an access code that opens Paystack’s popup on the page. /api/pay/verify takes the reference afterwards and checks it with Paystack before anything is marked paid:
const paidAmount = Number(tx.amount);
const expected = Math.round(Number(order.total) * 100);
if (paidAmount < expected || tx.currency !== order.currency) {
return json({ ok: false, status: 'amount_mismatch' });
}
// only then: mark the order paid via wc/v3
A redirect back from a payment page is a claim, not a receipt. Verify on the server, compare the amounts, then update the order. WooCommerce sends its normal order emails from there, so the owner’s routine doesn’t change.
7. Make the site rebuild itself. This is the step that makes the whole thing viable, because without it the owner edits a price and the site stays wrong until a developer notices.
The simplest version takes five minutes. Cloudflare gives every Workers project a deploy hook, a URL that starts a fresh build when anything POSTs to it (Settings - Build - Deploy hooks). WooCommerce can send a webhook when a product is created, updated or deleted (WooCommerce - Settings - Advanced - Webhooks). Point the webhook at the deploy hook URL and you’re done: edit a product, and two or three minutes later the site has rebuilt with the new data.
The version I run in production adds a Worker in between, because I wanted two things the simple version can’t give me: the webhook’s signature checked before anything builds, and five quick edits collapsing into one build instead of five.
8. Hand it over. The owner gets the WordPress login and nothing else. They don’t know there’s a Worker, a repo, or a build. On a laptop they use the WordPress admin. On their phone they use the WooCommerce app (iOS and Android), signed in with the same WordPress login: it shows new orders as notifications, lets them mark an order as completed from the delivery van, change a price, or switch a product to out of stock while standing in the cold room. Every one of those saves goes through the same webhook, so the site updates itself.
That last part is the whole point. I’ve watched it happen over WhatsApp: “I changed the mango price, is it supposed to show already?” Yes. It is.
What it costs
This is the part that makes it my default for this market, so I’ll be specific. Rough numbers that move around, but the shape holds.
This stack, per month
- Cloudflare Workers$0, or $5 past the free tier
- WordPress hostinga few thousand naira
- Builds$0
- Per salePaystack only: ~1.5% + ₦100, capped at ₦2,000
Shopify Basic, per month
- Platformabout $29–39
- Hostingincluded
- Buildsn/a
- Per salePaystack’s fee, plus Shopify’s own ~2% for a third-party gateway
The Shopify line that matters is the quiet one: that extra two percent on every sale. It doesn’t sound like much until a store does ₦1m a month and hands over ₦20,000 for a checkout page it didn’t want. Over a year that’s more than the WordPress hosting costs. The headless SaaS commerce platforms are worse: their pricing pages usually end in “contact sales”, which for a boutique in Lagos is a polite no.
And what it buys
Speed is the other half of the argument, so here’s the comparison that matters to a shopper on a phone: how long before the first byte of the product page arrives.
Time to first byte, product page, typical
Ranges from my own builds and the usual numbers for shared PHP hosting. Your mileage varies with the host, the theme and the plugins; the gap doesn’t.
The static number isn’t a caching trick that can go stale or get evicted. The page is a file sitting on the edge node nearest the shopper, and it stays there until the next build replaces it.
Things that bit me
These are the ones I’d want to be told about before starting.
The Store API will make you think someone paid. When you POST to /checkout, the response includes payment_result.payment_status: "success". That means the order was placed. It does not mean money moved. I had a shortcut gating on that field, and it silently skipped the Paystack popup. Orders landed as “pending payment” while customers thought they were done. If you need to detect a genuinely zero-total order (a coupon covering everything), check cart.totals.total_price === 0 before checkout, not the payment result after.
Your dev server’s catalogue is frozen at start time. I removed a size from every product in WooCommerce and then spent a confused minute staring at “Missing attributes for variable product”. My dev server had been running for three days with the old catalogue in memory. In production the rebuild pipeline makes this impossible. Locally, restart after catalogue changes.
Options and variations drift apart. A variable product has an options list (“M, L, XL”) and actual variations behind it, and nothing forces the two to agree. If an option exists with no variation, add-to-cart fails for that one size only, which is a maddening bug to get reported. I keep a small script that walks every product and checks each option has a published, priced variation. I run it after any bulk edit.
Virtual products change the shape of checkout. A cart with only gift cards has needs_shipping: false and no shipping rates. If your checkout insists on a delivery choice, you’ve made gift cards impossible to buy. Branch on it.
Mirror the Worker in dev. Astro’s dev server doesn’t run your Worker, so I mirror its routes with a Vite proxy in astro.config.mjs: /api/store goes straight to WooCommerce, the rest of /api hits the live Worker. Same-origin in dev and prod, no environment-specific client code.
Where I’d use something else
This assumes a catalogue measured in dozens or hundreds of products, not hundreds of thousands. A full rebuild per catalogue change stops being cute at scale. It also assumes stock isn’t changing every second; the product page shows build-time data, and only the cart enforces live availability. If you need per-second inventory on the product page, real-time pricing, or thousands of catalogue edits a day, you want request-time rendering and this isn’t that.
It also has more moving parts than one platform: WordPress, a repo, a Worker, a workflow, webhooks. Each piece is simple, but when something breaks it’s me who has to know which seam it broke on. I think that’s a fair trade, because every seam is replaceable. Swap Paystack for Stripe, swap WooCommerce for any backend with a decent API, and the architecture doesn’t move.
Start from here
If you want to try it, this is the prompt I’d hand to Claude Code or Cursor to scaffold the stack the way it’s described above. Copy it, paste it into a fresh project folder, and fill in the two placeholders. It won’t finish the store for you, but it will put the skeleton in the right shape, which is most of the thinking.
The point
None of the individual tools are the trick. The trick is refusing to make one system serve two masters. WooCommerce is a great back office and a mediocre storefront. Astro is a great storefront with no back office. A Worker is the thinnest possible glue between them: a proxy, two payment endpoints and a webhook receiver, in one file.
The owner lives in an admin they already knew, on a laptop or in the WooCommerce app on their phone. The shopper gets a site that opens before they’ve finished deciding whether to wait. And when the catalogue changes, the site rebuilds itself and nobody thinks about it.
That’s the stack. The tweet was right.