Skip to content
mroot.co
← All writing
Case StudiesJun 25, 2026 · 2 min read

Building a two-sided marketplace with an order state machine

A Next.js marketplace with a Hono API mounted at the catch-all route, an order lifecycle that runs as a state machine, and realtime messaging that can't roll back a transaction.

Project: Atlas Marketopen →
97
API endpoints
377
tests
3
role dashboards

Atlas Market is a marketplace for a GTA roleplay economy. It isn't just listings — it's the full loop: buyers order and negotiate, suppliers fulfill, and staff moderate. Three roles, three dashboards, one order lifecycle — and the reason that lifecycle is a state machine instead of a loose set of status flags is that a marketplace can't afford an order landing in an impossible state, like "paid" and "cancelled" at once.

One backend, mounted inside Next.js

The API is a Hono app bridged into Next.js through a single catch-all route. Next.js handles the pages; Hono handles every /api/* request. Keeping the backend as a standalone Hono app means the route logic isn't tied to Next.js internals and the whole API is testable on its own.

Neon serverless Postgres is the store, Clerk handles auth with a Discord-guild allowlist gate on top, Ably powers realtime messaging, and images go to R2.

Orders are a state machine, and money math is deterministic

An order moves through defined states (PENDING → ACCEPTED → …), and illegal transitions return a 400 instead of silently corrupting state. Creating an order touches three tables — the order, its items, and a message thread — so it runs in one transaction. If any insert fails, none of it lands.

The realtime notifications fire after the transaction commits, on purpose, so a flaky notification can never roll back a real order:

src/api-server/_routes/orders.ts
const result = await withTransaction(async (tx) => {  const [order] = await tx`INSERT INTO orders (...) VALUES (...) RETURNING *`  for (const item of items) {    await tx`INSERT INTO order_items (order_id, item_id, ...) VALUES (...)`  }  const [thread] = await tx`INSERT INTO threads (order_id, ...) RETURNING *`  return { order, thread }}) // Publish notifications outside the transaction (non-fatal if they fail)await Promise.allSettled(participants.map((uid) =>  publishToChannel(`user:${uid}:notifications`, 'new_thread', { ... })))

Totals are rounded to cents with integer math to avoid floating-point drift, because a price a buyer acts on shouldn't wobble.

Tested like it matters

97 endpoints, 377 tests across unit, integration, and Playwright end-to-end. The order lifecycle and its illegal-transition rejections are covered directly — that's the part where a bug means someone's order silently breaks.

What I'd improve

  • The package name is a leftover from a template scaffold; it should say what it is.
  • Some route modules do their own validation ad hoc; a shared Zod layer at the edge would tighten it.
  • Realtime and REST both describe the domain; there's room to unify the event names so they can't drift.

One of those route modules is also where I found and fixed a SQL injection bug after this shipped — see how I remediated it without breaking any callers.

Marc Delacruz — full-stack, security-minded.Get in touch →