A Next.js 16 organic-grocery store where the storefront and a role-based admin / moderator / customer dashboard share one auth session and one MongoDB. Built to survive real-world write races, not just to render a landing page.
A small store needs one codebase that lets customers browse, order, and pay while staff manage catalogue, inventory, orders, banners, and timed promotions from the same site. The Bangladesh context adds cash-on-delivery and BDT-first pricing (with admin-managed multi-currency), so I couldn't just fork a US-first Shopify demo and rename it.
The interesting part isn't the shopping cart. It's making concurrent orders behave, keeping an audit trail that survives a user being deleted, and running role-gated dashboards without leaking Prisma or bcrypt into the Edge runtime.
.js vs .jsx is load-bearing).
mongodb provider. MongoDB replica set is
a hard requirement — the checkout depends on
prisma.$transaction().
auth.config.js — Prisma and
bcrypt never enter the Edge bundle.
@tailwindcss/postcss.
/public/uploads
in dev, @vercel/blob in prod, switched at
build time in next.config.mjs.
fix0X-*.spec.js, one per bug I've had to
catch twice.
19 models, 9 enums, 567 lines of schema. The ones that pull weight:
User with
Role {CUSTOMER | MODERATOR | ADMIN},
distinct username? +
email, and an
isSuperAdmin flag on the first user
(undemotable, by design).
ApprovalRequest — moderators file
PRODUCT_DELETE and
ORDER_CANCEL intents into a queue
admins review. Nothing destructive happens without an
admin's second signature.
ProfileChangeRequest — email & phone
edits go through the same review loop, because
they're account-recovery channels.
ProductOffer — timed % discount that
becomes the real price everywhere; expiry is by
wall-clock only, no cleanup job needed.
Order / OrderItem /
OrderStatusEvent — money stored in
integer cents, item and address snapshots per order
so a past order never mutates when a product does.
AuditLog on every privileged write, with
actorId nullable and set to null on user
delete — the audit trail outlives the actor.
StoreConfig — one row, id:"store",
holding active currency and admin-managed BDT-base FX
rates.
maxAge. Idle
windows differ by role — 6 h for customers, 12 h for
admin/moderator — enforced inside the
jwt callback.
GOOGLE_CLIENT_ID isn't set, that
button never renders. No dead-button surface area.
requireRole() in the server
component asks "right role?", and the
server action re-checks before it writes.
Product.createdById === user.id.
session.user.role lives on the JWT, so
role checks never hit the DB on a normal request.
I'd rather be honest here than embellish. Here's the split as it stands today:
next.config.mjs ships with
minimumCacheTTL: 0 for dev
iteration — needs a prod value.
I kept string cuids as @map("_id") so ids
stay stable across the older MySQL prototype's data
shape. The trade: Prisma's Mongo adapter has no
native Decimal, so every price is
stored as integer cents and formatted on read. Simpler
migrations were worth the small formatting cost.
auth.config.js holds providers only and
is imported by the middleware — that keeps it
edge-safe. lib/auth.js (Node runtime)
adds Credentials, PrismaAdapter, and DB events on
top. Prisma and bcrypt therefore never end up in the
Edge bundle, and the middleware stays under the
size cap.
Every request reads role from the token. That's a DB round-trip saved on every page, at the cost of never being able to demote a live session instantly. Write-time defence lives in the server actions — which is where destructive intent lives anyway — so a stale role can't cause a wrong write.
placeOrderAction runs inside
prisma.$transaction(). Prices are
recomputed from the DB (anti-tampering) and each
line item's stock is decremented with a guarded
updateMany({ where: { id, stock: { gte: qty } }
}). If the returned count === 0 for any
item, a concurrent order won the race — the whole
transaction rolls back and the customer sees a
clear "out of stock" instead of oversell. It
requires a MongoDB replica set, which the
deployment docs call out up front.
The obvious way to link privileged actions to their
actor is a required foreign key. That makes user
deletion painful: either you cascade the audit log
away (losing history) or you refuse the delete.
Ecobazar takes a third path —
AuditLog.actorId is optional with
onDelete: SetNull. Deleting a user
preserves every privileged action they ever
performed; only the actor pointer is cleared.
Enforcement is by convention: every mutating server
action appends an AuditLog row before
it returns.
The repo has a 796-line DOCUMENTATION.md, a
seed script that boots 3 users and 10 products in one
command, and the full Playwright regression suite.