Case Study • Full-Stack E-Commerce & Deployment Engineering
CBlaze
A direct-to-consumer luxury wristwatch storefront built end-to-end: admin-driven catalog and pricing, live Paystack checkout with its own settlement account, and a self-hosted deployment moved off Vercel onto existing cPanel infrastructure.
- Client
- CBlaze, direct-to-consumer wristwatch retailer
- Role
- Sole engineer, architecture, backend, deployment
- Stack
- Next.js 16 (App Router) • MongoDB • Redis • Paystack
- Status
- Live in production visit ↗
01 Problem
A waitlist page, not a store
CBlaze started as a single landing page sitting at the root domain, collecting emails for a launch that hadn't happened yet. The brief was to turn that into an actual storefront: a real catalog, a real checkout, and the part that mattered most day to day - a backend the business could run itself, without a developer touching code every time a price changed.
That requirement shaped almost every decision that followed. A store that needs a code change and a redeploy to run a sale, adjust shipping, or restock an item isn't really self-service; it's a storefront with a developer standing behind it indefinitely. Three things had to be true from day one:
- Pricing had to live in the database, not the code. Every price a customer sees needed to trace back to a value an admin actually set, on every page, with no exceptions.
- Checkout had to survive the real world. Dropped connections, closed tabs, and slow mobile networks are normal, not edge cases, and a payment succeeding on Paystack's side had to reliably become a completed order regardless of what happened to the customer's browser afterward.
- Hosting couldn't add a new recurring cost. The business already pays for cPanel hosting for other purposes; running CBlaze there instead of on a metered platform was a real requirement, not a nice-to-have.
02 Approach
Building the store, then moving it off the platform that made building it easy
The build itself is a fairly conventional Next.js storefront over MongoDB. The more interesting engineering happened at the edges: getting pricing to a state where it genuinely couldn't disagree with itself, making checkout resilient to a customer's browser disappearing mid-flow, and then relocating the entire thing from Vercel - which had handled build, deploy, and runtime invisibly - onto shared hosting, where all three of those became this project's problem for the first time.
lib/pricing.ts • getEffectivePrice()
One function, every price
The pricing model went through two shapes. The first treated a sale price as a manually-set badge on individual products - a "was / now" pair applied to whichever items happened to be on sale. That didn't match how the business actually wanted to run promotions: any watch could go on sale at any time, and when it wasn't, the page should show nothing but its regular price. The fix collapsed both states into a single field and a single function - every product carries a discount (flat or percentage, defaulting to zero), and getEffectivePrice() is the only place in the codebase allowed to resolve what a customer actually pays. The homepage, the catalog grid, and the product detail page all call it instead of reading a price field directly.
Found in review
Homepage product cards were reading a separate price than the catalog and product detail pages.
Traced to the homepage's featured-products section querying prices directly instead of routing them through the shared pricing resolver - invisible until an admin edited a price in the dashboard and the homepage kept showing the old number while the rest of the site had already updated. Fixed by making every price-bearing surface call the same function, so there is no second code path left to drift.
Screenshot: Product Card Pricing
lib/orders.ts • createPendingOrder() / finalizeOrder()
Two-phase checkout, one idempotent finalize
An order is created in two steps, deliberately not one. createPendingOrder() first writes the order as pending, then calls Paystack to initialize the transaction; if that call fails, the pending order is deleted rather than left behind. A failed call to a third-party payment API should never leave an orphaned order sitting in the database as if a customer had started a checkout that, in fact, never got far enough to reach Paystack at all.
Confirming payment afterward is the more interesting half. finalizeOrder() is called from two independent places - the page Paystack redirects the customer back to, and Paystack's own webhook - because neither one alone is reliable. A customer can close the tab before the redirect completes; a webhook can be delayed or, in this project's case, isn't even reachable for every order (more on that below). Both triggers call the exact same function, and the function is written so that whichever one arrives first wins: it updates the order with a filter of paymentStatus: "pending", and only the update that actually matches that condition proceeds to decrement stock, update the customer record, and send the confirmation email. The second trigger to arrive finds the filter no longer matches and returns already_finalized - no double stock decrement, no duplicate email, no race.
The same function also declines to fail loudly when it probably shouldn't. If two customers finalize against the last unit of the same watch at nearly the same moment, the loser doesn't throw an error into a real transaction that already has the customer's money - the order is flagged [OVERSOLD: sku] in its notes instead, so the business finds out and can act, rather than the checkout appearing to break for a payment that already succeeded.
Settings model • InventoryLog • Redis
Operational numbers that don't need a redeploy
Shipping rates and tax were fixed constants in an early version of the code. They moved into a database-backed Settings document, editable from the dashboard, once it became clear those numbers change more often than the code around them does. Every stock mutation writes an InventoryLog row with a reason - sale, restock, manual correction - so stock history is an actual log to audit, not just a mutable integer sitting on the product. Upstash Redis backs rate limiting on the public API routes, so the storefront's own traffic can't be used to hammer the database.
Screenshot: Admin Dashboard
next.config.ts • middleware.ts
Leaving the platform that made this easy
Vercel had been handling build, deploy, and runtime without any of that being this project's concern. Moving to shared cPanel hosting under Phusion Passenger meant becoming responsible for all three at once. output: "standalone" turns the Next.js build into a self-contained Node process - straightforward to enable, but standalone output does not copy public/ or .next/static/ into the deploy folder by default, which is not obvious until the first deploy serves a site with no images and no styling. The admin middleware needed runtime: "nodejs" set explicitly, too: it uses a Node-only auth check that Vercel's edge runtime had been tolerating silently, and a plain Node process under Passenger will not.
.next/standalone/.next/node_modules • tar --dereference
A symlink that only existed on one machine
Next's standalone output traces the exact dependencies a build needs at runtime and copies them into the deploy folder - except when a dependency has been hoisted to a shared node_modules folder by the monorepo's package manager, in which case Next records it as a symlink rather than copying it, on the assumption that the symlink will resolve on whatever machine runs the build. Zipping that folder with Windows' own archiver, and later re-transferring it with a plain tar, both preserve that as a link to an absolute path on the local Windows machine, not as the package itself. Nothing about that is visible until the app boots on the Linux server and fails to resolve mongoose, because what it actually has is a shortcut pointing at a folder that never existed anywhere but the machine that built it.
Fix
tar -czhf - -C "$LOCAL/.next" node_modules | ssh host \ "cd app && rm -rf node_modules && tar -xzf -"
The -h flag (--dereference) makes tar follow the symlink and archive the real package contents instead of the pointer, which is what actually needs to exist on the server.
CloudLinux LVE • cagefs
Diagnosing a resource ceiling from inside the box that hit it
After a routine app restart, the site and SSH access both went fully unreachable at the same time - not a 500 page, an outright connection refusal on both ports, for several minutes. cPanel's own browser-based Terminal, which needs to fork a process the same way SSH does, surfaced the actual cause directly: cagefs_enter: Unable to fork, a CloudLinux LVE resource ceiling. The account's own Resource Usage panel narrowed it precisely - Physical Memory sat at 19%, nowhere near its limit, while Number of Processes read 80 / 80, pinned exactly at the account's ceiling, almost certainly orphaned Node worker processes from earlier restarts that had never been reaped.
The account genuinely could not fork a new process to diagnose itself, which meant the fix wasn't code - it was a support ticket, written with the specific saturated metric already isolated instead of a generic "the site is down."
| Resource | Usage at time of outage |
|---|---|
| Physical Memory | 597.3 MB / 3 GB (19.4%) |
| Number of Processes | 80 / 80 (100%) - the actual cause |
| Disk Usage | 1.33 GB / 30 GB (4.4%) |
lib/paystack.ts • subaccount / bearer
One Paystack account, two businesses, correct settlement
Live payments needed to settle into CBlaze's own bank account, but the underlying Paystack account already runs live transactions for a separate business. Rather than open a second Paystack integration, CBlaze's proceeds route through a subaccount under the same account: the same live API keys, a subaccount code attached to every transaction, and percentage_charge: 100 so the full amount settles to CBlaze's own account while the parent account absorbs Paystack's processing fee. Before creating the subaccount, the destination bank account was resolved against Paystack's own name-lookup and checked to match exactly, rather than trusting a manually typed account number. The live credentials were then verified with a direct API call - a real transaction/initialize request against Paystack's live endpoint that returns a valid checkout URL - confirming the wiring worked before any real customer's card was involved.
scripts/finalize-one.ts
A reconciliation script for the one redirect that can't be trusted
The redirect Paystack sends a customer back to after payment isn't guaranteed to complete - a dropped connection or a closed tab leaves an order stuck pending even though Paystack already confirmed the charge. Normally the webhook would catch that case, but a Paystack account only supports one live webhook URL, and this one is already committed to the other business sharing the account. Rather than build a shared webhook router immediately for a gap that's rare in practice, a small script calls the exact same finalizeOrder() function production uses, directly, against a payment reference - the identical idempotency guarantee, just triggered by hand instead of by a network callback.
It found real use the same day it was written: a live-credentials test completed payment successfully, the redirect landed on localhost from a phone that could never reach it, and the script closed out the order correctly without ever touching the broken redirect.
03 Architecture
How it's actually wired together
| Concern | Approach |
|---|---|
| Frontend | Next.js 16, App Router. Server components for the storefront and admin, with client islands for the cart, checkout form, and dashboard interactions. |
| Database | MongoDB Atlas via Mongoose. Development intentionally points at the same cluster used in production rather than a separate copy, to keep seed data and real orders in one place during active build-out. |
| Cache & rate limiting | Upstash Redis, fronting the public storefront API routes. |
| Auth | Cookie-based admin session, gated by Node middleware - deliberately not Edge, since Passenger runs a plain Node process rather than an edge network. |
| Payments | Paystack, live keys with a dedicated subaccount, redirect-based checkout backed by an idempotent finalize path triggered independently by both the webhook and the customer's return redirect. |
| Media | Cloudinary for product image upload and delivery. |
| Nodemailer over the business's own SMTP mailbox for order confirmations, rather than a third-party transactional email platform. | |
| Deployment | Next.js standalone output, built locally and deployed by hand over SSH to shared cPanel hosting under Phusion Passenger / CloudLinux. No CI, no platform build step - the build artifact is produced locally and shipped as-is. |
04 Result
What actually changed
CBlaze is live and taking real orders. The concrete outcomes, measured against what the brief actually asked for:
Pricing
Every price on every surface resolves through one function. There is no remaining code path that can show a stale or hardcoded number.
Checkout reliability
Payment confirmation survives a dropped connection or a closed tab - two independent triggers reach the same idempotent finalize path, with a manual reconciliation tool for the rest.
Hosting cost
Runs on infrastructure the business already owns. No added recurring platform fee for the storefront itself.
Payments
Live and settling directly to CBlaze's own account, verified end-to-end before the first real customer transaction.
Self-service operations
Shipping, tax, pricing, and inventory are all editable from the dashboard directly. No code change, no redeploy, for day-to-day changes.
Incident response
A production outage was root-caused to a specific saturated resource, not a guess, using only what the account itself could still report before it could fork a new process again.