Skip to content
2026 · Solo — design, frontend, backend, deploy

Crown Kids

An online store for a kidswear brand.

Next.js 16 (App Router)TypeScript (strict)Postgres (Neon)PrismaBetter AuthRazorpayCloudinaryVercel

A kidswear manufacturer was selling through resellers and WhatsApp orders, and wanted their own store.

I looked hard at Shopify first. It would have been running in a week. I went custom anyway, because the inventory rules were specific enough that I'd have spent the same time fighting the platform — and because I wanted to learn the parts of commerce that a hosted platform hides from you.

It is a full storefront and an admin panel: guest checkout, server-priced carts, product and collection management, order handling, and Razorpay payments with a signed webhook. Roughly 90 commits over a month.

Here are the three problems that took the longest, and that I'd now ask about if I were interviewing someone.

The browser doesn't get to decide what things cost

The first version of my cart sent the price along with the product id. It worked. It was also trivially exploitable — open devtools, change the number, check out at whatever you like.

So the client now sends ids and quantities only. The server looks up the current price, checks stock, and builds the total itself. The cart in the browser is a display of a decision made elsewhere, not the decision.

Prices are stored in paise as integers. Floating point and money don't mix — 0.1 + 0.2 is not 0.3, and that difference eventually shows up in someone's invoice.

Two buyers, one dress left

Read the stock, check it's enough, write the new stock back. Two requests can both pass the check before either one writes, and you've sold the same dress twice. On a small store you might not notice for months — you just get an angry phone call.

The fix is to stop asking and then telling. One statement that only applies if the condition still holds, evaluated by the database under a row lock. Prisma's updateMany with a guard in the where clause compiles to exactly that, and returns how many rows it actually touched.

READ COMMITTED is enough here: the row lock re-evaluates the stock condition against the committed value, so SERIALIZABLE would only add retries. Lines are locked in variantId order, so two overlapping orders cannot deadlock holding each other's rows.

Payment webhooks arrive more than once

Razorpay retries a webhook until it gets a 2xx. If your handler is slow, or throws after doing half the work, the same payment.captured event turns up again. Handle it naively and you fulfil one order twice.

Two things fix it. Verify the signature against the raw request body, before anything parses it — parsing and re-serialising changes the bytes and the HMAC no longer matches. Then treat the event id as an idempotency key: insert it first, and let the unique constraint reject the duplicate before any real work happens.

timingSafeEqual rather than === so the comparison doesn't leak the correct signature one byte at a time.

What I’d do differently

  • 01I'd put orders and stock changes behind a single transaction boundary from day one instead of retrofitting it. Getting there afterwards meant re-reading every write path.
  • 02I'd write the admin side first. I built the storefront, then discovered the people actually running the shop needed a completely different set of screens, and their needs changed the data model.
  • 03Three months at an hour a day beat every weekend I've ever spent planning a rewrite.