
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.
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.
await withStore(storeId, async (client) => {
// every query in here is scoped to storeId
return getProductsForStorefront(client);
});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 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.
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.
- 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.
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.