Commerce
Commerce Platform
A headless admin that runs many stores, and a storefront that consumes it.
- Year
- 2024
- Role
- Sole engineer — architecture, both applications
- Type
- Commerce
What it is
This is two applications rather than one. The admin is a dashboard and an API: it owns products, categories, billboards, orders and Stripe, and it can manage any number of separate stores from a single deployment. The storefront is a thin, fast client of that API — it holds no business logic, and a second storefront can be pointed at the same admin without touching the admin at all.
By the numbers
- 2
- Deployable applications
- 1
- Shared headless API
- N
- Stores per admin instance
- 01StorefrontNext.js
- 02Store APIscoped by id
- 03PrismaPostgreSQL
- 04CheckoutStripe
- 05Webhookidempotent
- 06Orderpersisted
The problem worth solving
The hard requirement was multi-tenancy. One admin instance owns N stores, and every single query, route handler and webhook has to be scoped so that store A can never read or mutate anything belonging to store B. Getting this wrong is not a rendering bug — it is a data breach.
The second hard requirement was payment correctness. Stripe does not promise to deliver a webhook exactly once. It promises at least once. If the handler is written naively, the same `checkout.session.completed` event arrives twice and the customer gets two orders for one payment.
What I built
Store scoping is structural rather than remembered. The store id is a route parameter, every data access is resolved through it, and there is no code path that queries a product without also constraining it to a store. Making the safe thing the only thing available is more reliable than making it the thing you have to remember.
The webhook handler verifies the Stripe signature, then treats the event id as the unit of work — an event that has already been processed is acknowledged and discarded rather than replayed. The order write is keyed so that a duplicate delivery cannot create a second order.
On the storefront, cart state lives in Zustand and persists across reloads, with SWR handling product data. The split matters: the cart is client-owned and must survive a refresh; the catalogue is server-owned and must stay fresh. Those are different problems and they get different tools.
Why split it into two apps
Because they have different jobs. The admin is authenticated, write-heavy, and only a handful of people ever load it — its bundle size barely matters. The storefront is public, read-heavy, and its load time is directly a conversion number.
Keeping them separate means the storefront ships almost no admin code, and the admin can grow without making the storefront slower. It also means a new client gets a new storefront deployment rather than a new fork of everything.
Decisions
Four calls I would make the same way again
01
Headless admin, not a monolith
The admin exposes an API rather than rendering the shop. That one decision is what makes N storefronts possible without duplicating business logic, and it keeps the public site's bundle free of dashboard code.
02
Scope in the route, not in the query
The store id lives in the URL and flows down through every data access. There is no unscoped query to accidentally call, so tenancy is enforced by the shape of the code rather than by reviewer vigilance.
03
Treat webhooks as at-least-once
Stripe will redeliver. Handling the event id as an idempotency key is a few lines and it is the difference between a correct ledger and double-charged customers.
04
Different state tools for different state
Zustand for the cart because it is client-owned and must survive reloads. SWR for the catalogue because it is server-owned and must not go stale. One global store for both would have been worse at both.
Stack
Admin + API
- Next.js 14
- Server Actions
- Prisma
- PostgreSQL
Storefront
- Next.js 14
- Zustand
- SWR
- Headless UI
- DaisyUI
Payments
- Stripe Checkout
- Signed webhooks
- Idempotent handlers
Next case study
Discord Clone