Moses Adebayo·

WordPress for the owner, Astro for the shopper: how I build online stores now

The stack I keep coming back to for small stores in Nigeria — WooCommerce in the back office, an Astro storefront on Cloudflare Workers out front, Paystack in between — and what it actually costs to run.

e-commerceastrowordpresswoocommercecloudflarearchitecturepaystack

A few months 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.

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.

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)
  ├── /api/pay/*          → Paystack init + verify
  ├── /api/order          → order summary for the confirmation page
  ├── /api/subscribe      → newsletter, straight into Workers KV
  └── /api/rebuild        → WooCommerce webhook → triggers a rebuild

admin.yourshop.com        → WordPress + WooCommerce
  (products, orders, shipping zones, coupons, order emails)

One domain for shoppers. One Worker in front of everything. WordPress is the system of record; it just never serves a public page.

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. 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.

4. 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.

5. 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.

6. 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.

WooCommerce product webhook (HMAC-signed)
  → Worker /api/rebuild verifies the signature
    → GitHub repository_dispatch: "catalog-update"
      → a four-line Actions workflow pushes an empty commit
        → Cloudflare Workers Builds sees the push
          → rebuilds with a fresh catalogue and deploys

The Worker checks the webhook’s HMAC signature before doing anything, so random POSTs to that URL go nowhere. The workflow has a concurrency group with cancel-in-progress, so when the owner edits five products in two minutes you get one fresh deploy instead of five queued ones.

7. 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. They edit a price on their phone, go and make tea, and the site has quietly updated by the time they’re back.

That last scene 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.

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.

What it costs

This is the part that makes it my default for this market, so I’ll be specific. Rough numbers, and they move, but the shape holds.

This stack Shopify Basic
Platform fee $0 (Workers free tier), $5/month if you outgrow it about $29–39/month
Hosting cheap PHP hosting for WordPress, a few thousand naira a month included
Payment processing Paystack’s local-card fee only, about 1.5% + ₦100, capped at ₦2,000 per transaction Paystack’s fee plus Shopify’s own cut (around 2%) on every sale, because you’re using a third-party gateway
Build / CI GitHub Actions, seconds per run, free n/a

The Shopify number is the quiet one. Two percent on top of Paystack doesn’t sound like much until a store does ₦1m a month and hands over an extra ₦20,000 for the privilege of 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: the pricing page usually ends in “contact sales”, which for a boutique in Lagos is a polite no.

The real cost of this stack isn’t money. It’s that I own the seams. More on that below.

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.

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. 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.

Previousyou can, but should you?