Case study
Fake E-commerce — Redis cart, Postgres orders
A production-minded Next.js storefront where I started with Redis JSON blobs for order speed, then migrated to Postgres + Prisma on Neon for relational integrity—without changing the public API.
1. Problem
v1 stored orders as Redis JSON blobs keyed by Clerk userId (orders:user:{userId}). That was fast to ship and fine for a demo checkout—but it broke down when I needed relational integrity, per-line-item queries, and an admin view over all orders.
Cart data stayed the right fit for Redis: session-scoped, high-churn key/value with no joins. Orders needed rows, foreign keys, and migrations. The migration had to swap the persistence adapter behind order-store.ts while keeping REST and GraphQL contracts identical.
2. Architecture
Two stores on purpose—each matches its access pattern:
┌─────────────┐ ┌──────────────────┐ ┌─────────────────────────┐
│ Browser │────▶│ Next.js App │────▶│ Redis │
│ (React 19) │ │ App Router │ │ • cart / session │
└─────────────┘ │ + Clerk auth │ │ • product cache │
│ └────────┬─────────┘ └─────────────────────────┘
│ │
│ ┌────────▼─────────┐ ┌─────────────────────────┐
└───────────▶│ /api/orders │────▶│ PostgreSQL (Neon) │
│ Prisma adapter │ │ Order + OrderItem │
└──────────────────┘ └─────────────────────────┘
Checkout flow: Cart (Redis) → /checkout/pay → /checkout/success → POST /api/orders → Postgres
- Cart — /api/cart/[sessionId] on Redis (in-memory fallback without REDIS_URL).
- Orders — /api/orders and GraphQL createOrder via Prisma; scoped by Clerk userId.
- Deploy — Neon pooled DATABASE_URL; prisma migrate deploy runs before next build on Vercel.
3. The migration
Four commits, same day—schema first, adapter swap, production bug fix, then docs:
-
f7440c3— Bootstrap Prisma 6 with Order + OrderItem schema, Docker Compose Postgres for local dev, initial migration. -
50fd84b— Replace Redis order-store with Prisma adapter; map models to existing Order type; Redis stays for cart only. 45/45 tests green. -
385f4a4— Fix duplicate order creation on /checkout/success (race condition deep-dive below). -
4575a38— README documents Redis cart vs Postgres orders and the migration narrative.
Before the adapter swap I locked Redis behavior with commit 9668a49 so the test suite would catch regressions during the Prisma cutover.
9668a49
4. Race condition deep-dive
After Postgres went live, checkout success sometimes created two orders for one cart. The bug lived in a useEffect on /checkout/success—not in Prisma. That’s the kind of issue that separates “it works on my machine” from production-aware React.
Root cause
- The effect depended on [addOrder, clearCart, total, cart, setOrderPaid, sendEvent]—unstable references on every render.
- hasSubmitted.current = true ran in .finally() after the async addOrder call—too late if the effect re-fired first.
- total was derived from async fetchProducts(), so dependency churn retriggered the effect while the first submission was still in flight.
Fix
- Set hasSubmitted.current = true synchronously before addOrder.
- Snapshot cart items at submit time (cart.map(...)) to avoid stale closures.
- Replace async product fetch with computeOrderTotal() from static catalog data.
- Depend only on [cart.length]—fire once when the cart hydrates; reset guard on API failure.
// Guard before async work — not in .finally()
hasSubmitted.current = true;
const items = cart.map((item) => ({ ...item }));
const orderTotal = computeOrderTotal(items);
// Effect deps: [cart.length] only — intentional
5. Outcomes
- 45/45 tests passing after the Prisma adapter swap (Vitest unit + integration).
- Live smoke test — sign in, add to cart, complete checkout; order appears in /my-purchases backed by Neon rows.
- Production URL with Clerk auth, SSG/ISR catalog, and Playwright checkout E2E in CI: mini-ecommerce-nextjs-psi.vercel.app
- Accessibility — Lighthouse 100 on home, products, PDP, and cart after landmark and labeling fixes.