Next.js · Case Study
AduraMart
AduraMart is a faith-goods marketplace for Nigeria. Vendors sell church garments, choir robes, oils, candles and fabric by the yard; buyers pay through Paystack and the vendor is settled after delivery, minus commission. One Next.js app serves the storefront, the vendor hub and the admin console, against a NestJS API that owns the database and every money decision.
01 Overview
AduraMart sells the things a white garment church actually buys: garments and girdles, choir robes, candles, oils and perfumes, devotionals, instruments, and fabric measured by the yard. Prices are in naira, addresses are Nigerian, and phone numbers are normalised to +234 whichever way they are typed. The system is two codebases. One Next.js 15 app serves three audiences on three hostnames, the storefront on aduramart.com, the vendor hub on vendors.aduramart.com and the admin console on admin.aduramart.com, all reached by a middleware rewrite rather than three deployments. Behind it sits a NestJS API on Postgres that owns the schema, the money and the moderation state. An earlier version of the storefront read from Supabase directly; that was replaced by the dedicated API, and the frontend now holds no database credentials at all.
02 The Challenge
A marketplace takes money from a buyer and then owes it to someone else. That is the whole problem. Every interesting failure sits in the gap: a buyer who double-clicks checkout must not end up with two chargeable references, a payment webhook that arrives twice must not pay a vendor twice, and a bank transfer that times out is not the same thing as a transfer that failed, because treating it as failed is how you pay someone twice. Money also has to survive arithmetic, so a marketplace that splits every order into a vendor share and a commission cannot afford floating point. And three audiences with different privileges, buyers, vendors and staff, had to be served without standing up and separately securing three applications.
03 The Solution
- 01Split the system in two: one Next.js 15 app for all three audiences, routed by hostname in middleware, and a NestJS API that owns the database and every money decision. The frontend ships eight runtime dependencies and no data-layer library, so every read is a fetch against one versioned contract and the web tier holds no database credentials.
- 02Stored every money column as Postgres NUMERIC(12,2) in naira, 22 of them, with no float anywhere in the schema. Kobo exists only at the Paystack boundary, converted on the way out and back. Order quantity is decimal too, because fabric is sold by the yard and half a yard is a real order.
- 03Made double charging impossible in the database rather than in application code. A partial unique index permits at most one live payment session per order, so a double-clicked checkout either reuses the existing Paystack session or loses the insert race and reads back the winner.
- 04Verified the Paystack webhook as HMAC SHA-512 over the raw request bytes, compared in constant time, before parsing the JSON. Applying the payment is a conditional update whose affected-row count decides whether courier booking and notifications run, so a replayed webhook changes nothing and fires nothing twice. An unknown reference is acknowledged rather than 500'd, so Paystack stops retrying it.
- 05Classified payout failures instead of retrying them blindly. A definite rejection releases the orders back to payable. A timeout, a 5xx or an OTP challenge becomes an unresolved payout that leaves the orders settled and waits for a human, because a transfer that timed out may still have moved money. The settlement transaction commits before the transfer call is made, so a crash mid-transfer leaves evidence rather than a clean slate.
- 06Made sessions single-use. The access token is a short-lived JWT; the refresh token is opaque, stored only as a digest, and rotated inside a compare-and-set, so presenting an already-used token revokes every session for that user. Because the API sits on another origin and its own cookie cannot be held by the browser, the app captures the API's Set-Cookie pairs, keeps them in a first-party cookie and replays them on refresh, with one single-flight guard per execution context since a single-use token cannot survive two concurrent refreshes.
- 07Closed routes by default with three global guards in order, throttle then authenticate then authorise, so forgetting a decorator on a new endpoint locks it rather than exposing it. The throttler deliberately fails open if Redis is unavailable; the auth guards do not.
- 08Modelled moderation as explicit state rather than flags. Vendors and listings each carry pending, approved, rejected, hidden and suspended, KYC runs separately through Smile ID so a vendor can be approved but not yet verified, and editing an approved listing sends it back to review. Five admin queues carry live counts.
- 09Covered the money paths with 1,278 tests across 91 files, twelve of them property-based, with the end-to-end suites running against real Postgres and Redis in CI rather than against mocks.
04 Key Features
- Vendor onboarding with admin approval, and Smile ID KYC tracked separately
- Listing review queue, where editing an approved listing returns it to review
- Paystack checkout with signature-verified, replay-safe webhooks
- Per-category commission with a minimum fee, snapshotted onto the order
- Admin payout runs with a dedicated unresolved state for ambiguous transfers
- Per-vendor delivery zones, with pickup forced when a vendor does not deliver
- Fabric priced and ordered by the yard in fractional quantities
- Delivery confirmed with a hashed handoff code, never stored in plaintext
