The Swan Creek Spirits store floor: spirits aisles on the left, checkout counter on the right

Flagship build · live in production

Swan Creek Spirits

A real Alabama liquor store, online end to end — browse the shelf, reserve a bottle, pay and show ID at the counter. Built solo, live in production, syncing ~1,100 products from the store's own register.

In the register feed

~1,100 products, synced automatically

Live on the storefront

~130 — a curation bar, not a bug

Gating every deploy

120 automated tests

Built for

2 stores — running 1, live in production

Numbers as of July 2026.

The brief & its constraints

One client owns two liquor stores — Swan Creek Spirits and Beaver Trap. Neither is a 'main' site; they are co-equal, each with its own domain, branding, and catalog, but they share an owner, a product philosophy, and an operator. The job was a real storefront for each (browse, reserve, pick up in person), a back office to run the catalog and orders, and an employee kiosk for the pickup counter — built in parallel rather than twice.

  • No online payments, ever — the legal gate for alcohol is the in-person ID check at handoff, not checkout. No processor, no PCI scope, no customer accounts.
  • Same owner, two stores — one platform runs both, with per-store separation designed in from the first migration.
  • One product line, not a SaaS — one thin liquor config and namespaced metadata, deliberately not a vertical-adapter framework.
  • A live store with real data — every change to production is backup-first and owner-authorized; that rigor is pushed down into migrations and deploys.

Architecture as-built

A request comes in at the edge and is answered by one Next.js application. The first thing that happens is resolution: the domain the request arrived on decides which store it belongs to, before any page logic runs. From there it lands in one of three surfaces — the public storefront, the owner's back office, or the employee dashboard — all of which call into a server-only core library where store separation, queries, auth, uploads, AI, and notifications live. The batch systems (POS sync, photo pipeline, backups) run off the request path.

Mobile read

One process, three surfaces

Storefront, owner admin, and employee dashboard share one Next.js app and one server-only core.

Isolation in the middle

Every store-scoped query runs through withStore(), which pins it to exactly one store — or refuses to run.

Batch jobs to the side

POS sync, photo processing, and backups run off the request path on their own schedules.

edge + TLSone hardened process behind itdomain → storeresolved before any page logicstorefrontshop / reserveowner admincatalog / merchandisingstaff surfacepickup orderssrc/lib core (server-only)withStore()every query scoped to one storeisolation lives heretenancyqueriesauth / uploadsai / notifications / ordersPostgresevery row tied to its storeBatch + ops: POS sync / photo pipeline / encrypted off-box backups
Scroll the detail view if you want the full map. Edge → domain-to-store resolution → three app surfaces → one server-only core where every query is pinned to its store. Batch jobs sit to the side.

Two stack choices set the tone. Data access is SQL-first: the database itself — not application code — is the source of truth for inventory correctness, so a picked-up order and its stock adjustment succeed or fail together. And server components are the default, so data access stays on the server and the store-scoping wrapper sits naturally between the UI and the database.

Store isolation & identity

This is the load-bearing decision. Every store-scoped query flows through a single wrapper that pins it to exactly one store, and the platform treats that boundary as non-negotiable: it is enforced in one place, exercised by the test suite on every push, and designed so it can be tightened even further without rewriting a single query.

ts
await withStore(storeId, async (client) => {
  // every query in here is scoped to storeId
  return getProductsForStorefront(client);
});
withStore() validates the store id, opens a transaction, and pins every query inside the callback to that store; nested queries inherit the scoped client, so the scope can't be 'forgotten' halfway down a call stack.

Which store a request belongs to is decided on every request, from the domain it arrived on — never from anything the client sends. Each store's identity is verified against the platform's own records before a single query runs.

Customers never create accounts. A reservation needs a name and a way to reach you — nothing more — so there is no customer password to steal and no public login to attack. Staff are separate: they sign in once and work only in the stores they've been given a role in.

Data model & order lifecycle

The schema's history is the project's history. The migration chain runs from a single-store prototype, through the multi-store pivot, through isolation plumbing, then features and operational hardening — every step applied in order, to a live store with real data.

Mobile read

Single → multi-tenant

The schema began single-store and pivoted to per-store scoping mid-chain, back-filling existing data.

Isolation, then features

Scoping plumbing lands before order states, reservations, and the inventory workspace.

One step was irreversible

The auth cutover permanently replaced the old structure — rehearsed on real data before the live run.

the migration chain, in orderphase 4 · states · reservations · inventoryphase 1prototypephase 2multi-storephase 3isolationphase 6ops + featuresphase 5 · the irreversible auth cutoverfirstlast
Scroll the detail view if you want the full map. Prototype → multi-store pivot → isolation → order states, reservations, inventory → the auth cutover → ops + homepage and gift-wizard features.

The catalog follows 'Model A': every distinct expression (flavor, label) is its own product with its own photo and UPC; only sizes fold together as variants, with a Line → Item → Size browse hierarchy layered on top for navigation rather than by merging records. Liquor attributes (ABV, proof, region) live in namespaced metadata.liquor.* JSON instead of bespoke columns or a generic attribute framework.

Reservations move through an explicit, enforced state machine — a loose early status field was replaced with a canonical set of seven states, and every transition is written to an audit trail.

Mobile read

One happy path

new → preparing → ready → picked_up is the intended flow through a reservation.

Explicit exits

no_show, expired, and cancelled are first-class terminal states, not edge cases.

Every move is audited

Each transition is written to an audit trail, so the history stays queryable.

newpreparingreadypicked_upcancelledno_showexpiredhappy pathexits
Scroll the detail view if you want the full map. new → preparing → ready → picked_up, with exits to no_show, expired, and cancelled. Solid = happy path; dashed = exits.

Orders carry a short confirmation code designed to be read aloud over a phone without ambiguity, and a double-submitted reservation cannot create a duplicate order. Notifications are queued, not fired inline: each intended message is recorded first and sent by a separate process, which makes retries and provider outages survivable.

The catalog pipeline

The owner's real job isn't data entry — it's reconciling a point-of-sale feed against a public catalog and deciding what is good enough to show customers. The catalog flows from an automated intake, through human curation behind a hard gate, to publish.

Mobile read

Automated intake

A POS CSV is matched on UPC; unmatched rows land in a review state instead of going live.

Human curation

The owner clears a review queue behind a six-check canon gate before anything publishes.

Assisted, not automatic

The photo pipeline and AI assist only suggest; nothing reaches the storefront without the gate.

POS CSVevery ~4himport · matchUPC · blacklistreview queueowner curatescanon gate6 checksstorefrontlivephoto pipelineAI assist
Scroll the detail view if you want the full map. POS CSV → import/match (UPC + blacklist) → review queue (owner curates) → canon gate (6 checks) → storefront. The photo pipeline and AI assist feed the review queue.
  • Inventory review workspace — an action queue of products in 'review', behind a hard canon gate: no publish without an image + a name + taxonomy + description + price > 0 + stock. Every action has an explicit undo.
  • POS sync — a scheduled worker (several times a day) matching a POS CSV on UPC (pos_sku fallback); unmatched rows land as 'review', and a durable 3-tier blacklist (UPC / SKU / name) survives re-syncs. A per-store advisory lock and row-count / stock-magnitude anomaly aborts stop a bad feed from corrupting the catalog, and a dead-man's-switch heartbeat catches a schedule that silently stops firing.
  • Photo pipeline — a local computer-vision pass classifies a pile of images, matches them by UPC, and stages them; the bulk importer is a dry-run preview first.
  • AI catalog assist — per-item, owner-triggered, accept/reject diffs; taxonomy is constrained to the existing allow-list. It never auto-applies and never auto-runs.

Production & the cutover

The production cutover was not a routine deploy. It was a first-ever deploy, an irreversible migration, and a single→multi-store data backfill, applied to a live store with real data in a single pass — which is exactly what made it the highest-risk moment in the build and worth engineering carefully.

Mobile read

Rehearse first

The full migration chain runs against a copy of real production data before the live run.

Backup-gated

A restore-verified backup precedes the irreversible migration step, never the other way around.

Verify at the end

Health checks confirm the live deploy is serving before the cutover is called done.

backup-gated · rehearsed on real datarehearsebackupmigrateship codeedge · TLSverify
Scroll the detail view if you want the full map. Rehearse on real data → verified backup → apply migrations → ship code → bring the serving stack live → verify. Every risky step is preceded by a proven safety net.

The risk was bought down before touching the box: the production backup was pulled, restored, and row-count verified, and the full chain was rehearsed end-to-end on a copy of real production data — the irreversible step included — before running for real. The stack itself is deliberately boring.

Web tier
deliberately small — no container fleet, no orchestration, nothing that needs babysitting
Edge
a hardened reverse proxy terminating TLS, certificates auto-renewed
Database
one Postgres database, deliberately close to the app — no extra moving parts
Backups
nightly, encrypted, stored away from the server — and the restore has actually been tested
CI
every push runs the full automated check suite before it is allowed to ship
Deploy
one command: build, verify, release — and roll back automatically if the health check fails

Trade-offs & what's deferred

Good engineering is as much about what you don't build. Each of these is parked deliberately, with a concrete trigger for revisiting it.

Online payments
deliberately never — the legal gate for alcohol is the ID check at the counter, so the site holds no card data at all
Customer accounts
deliberately none — a reservation needs a name and a way to reach you, not a password
SMS notifications
revisit once there's a verified sender + sign-off on voice
A generic vertical / SaaS framework
revisit only for a genuinely different second vertical

The payoff: Beaver Trap joins as an additive store — a row, a theme, its own catalog and homepage data — not a fork. The only per-store divergence (homepage composition and gift-wizard behavior) is driven by data and config, never code branches.

Ben Dixon

Developer / Engineer in Athens, Alabama. Websites, ecommerce, internal tools, and the systems that keep them useful after launch.

Copyright 2026 Ben Dixon

Start a project