Skip to content
All work

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
Request path for a purchase
  1. 01StorefrontNext.js
  2. 02Store APIscoped by id
  3. 03PrismaPostgreSQL
  4. 04CheckoutStripe
  5. 05Webhookidempotent
  6. 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