Ecobazar Building My First Full-Stack E-Commerce Store (Next.js 16, MongoDB, NextAuth v5)
A step-by-step teardown of a real Bangladeshi organic-grocery store I built from scratch.
#Live Demo
#Repo LinkThis is my first full-stack project and I wanted to write down every single thing I built and learned — the tech stack, every feature, every design decision, and the parts that broke and how I fixed them. If you're a beginner learning full-stack, I hope this saves you a few weeks. If you're a hiring manager reading this, everything below is real, deployed, and answering HTTP 200 as I type this.
📚 Table of Contents
What is Ecobazar?
Ecobazar is a full-stack e-commerce store for Bangladeshi organic groceries. It has two faces running on the same domain:
Storefront — the public shop. Browse categories, search, filter by price + star rating, view product details with an image gallery, add to cart, checkout as a guest or signed-in user, track your order, request a return within 15 days of delivery, write a review after your order arrives.
Dashboard (
/dashboard) — a role-based admin area. Depending on whether you're a CUSTOMER, MODERATOR, or ADMIN, you see a different dashboard with different powers. Admins can create/edit/delete products, manage users, promote or demote roles, upload promo banners, set up time-limited Hot Deals offers, approve profile-change requests, and read the audit log.
The store is Bangladesh-oriented — prices in Taka (৳), districts and thanas in the checkout form (no US zip codes), Bangla product names alongside English (Deshi Aloo, Kacha Morich, Ilish Mach, Sundarban Honey…).
Everything is real code, running now. No mock APIs, no lorem-ipsum products. 59 products in the catalogue, 12 categories, image gallery on the biggest product, full order lifecycle from cart → checkout → PAID → SHIPPED → DELIVERED → optional 15-day return with restock.
The numbers, at a glance
Real counts, straight from grep:
| What | Count |
|---|---|
Source files (.js + .jsx) under app/, components/, lib/ |
137 |
| Total lines of code (source only) | 16,055 |
Route pages (page.js / page.jsx) |
26 |
Prisma models (in schema.prisma) |
19 |
Server actions files ("use server") |
12 |
| Zod schemas (files importing zod) | 14 |
| Products in the seeded catalogue | 59 |
| E2E test spec files (Playwright) | 18 |
useState calls |
149 |
useEffect calls |
30 |
useTransition calls |
36 |
useRouter calls |
31 |
useRef calls |
23 |
useCallback calls |
15 |
useContext calls |
8 |
useMemo calls |
7 |
useSearchParams calls |
6 |
usePathname calls |
4 |
useReducer calls |
2 |
Plus two custom hooks: useT() for i18n (85 uses across the app) and useCart() for the cart context (22 uses). More on these below.
Full tech stack
Every piece of this stack was a deliberate choice with a reason. Nothing is here "because tutorials use it".
Frontend
Next.js 16 (App Router) — file-based routing, server components by default. Server-first rendering means most of the app doesn't ship JavaScript for its rendered HTML, only for the interactive bits. Turbopack is the bundler (default in 16).
React 19 — server components + client components + server actions.
Tailwind CSS v4 — utility-first CSS. v4 keeps its config in CSS (
app/globals.css), not in atailwind.config.js.JavaScript — this project is plain JS, not TypeScript. First full-stack project meant one less thing to fight with. I might migrate to TS later.
Backend
Prisma 5.22 — ORM for the database. Type-safe queries, migrations (well,
db pushfor Mongo since it has no migrations), model definitions inprisma/schema.prisma.MongoDB 8.0 as a single-node replica set — MongoDB itself, but running in replica-set mode. Why? Prisma's MongoDB connector needs a replica set to run
$transaction, and my checkout logic needs transactions to prevent overselling. More on that later.NextAuth v5 (Auth.js) — authentication. Credentials (username/email + password), plus optional Google/Facebook OAuth that auto-mounts only when the env vars are set.
Zod 3.23 — runtime input validation. Every server action and API route parses inputs through a Zod schema before hitting the database.
bcryptjs — password hashing (pure-JS bcrypt so it works everywhere).
Deployment / DevOps
Ubuntu 24.04 VPS (OpenVZ container, 1 GB RAM, 1 CPU, 9.8 GB disk) at
eco.shanto.dev.nginx 1.24 as reverse proxy on ports 80 + 443, with a friendly maintenance page for when the app is down.
PM2 7.0 as the Node process manager, with
--max-memory-restart 500Mso a runaway worker triggers a controlled restart before it OOM-kills the whole box.Let's Encrypt via certbot, auto-renewed by a systemd timer.
ufw firewall — 22, 80, 443 in, everything else deny.
GitHub Actions for CI/CD — every
git pushtomaintriggers a build on GitHub's runners (7 GB RAM, no OOM risk), which then rsyncs the artifacts to the VPS.
Testing
- Playwright for end-to-end tests. 18 spec files, 70+ test cases.
Tools during dev
ESLint (flat config,
eslint-config-next) for linting.sharp (bundled with Next.js) for image processing — I wrote a script that resized all 61 product photos from 156 MB down to 3.4 MB in place.
Feature list — everything I built
Storefront (customer-facing)
Home page — hero grid with a main promo image + a live TOP-placement admin banner (falls back to a static image if no banner is set) + a Hot Deals link card, service bar (free shipping / support / secure payment / money-back), category tiles, "Popular Products" best-sellers row, Hot Deals area with a big featured card + smaller card grid, a BELOW_LIST admin banner slot, "Featured Products" row, and a "Customer Reviews" carousel with 6 seeded reviews (4× 5-star, 2× 4-star — I purposely didn't make them all 5 stars because all-5 looks fake).
Nav bar — Home · Shop · Pages · Track Order · About · Contact. "Track Order" wires to
/orders/lookup, my guest tracker.Shop page — server-side paginated grid (9 products per page). Live search by name. Category filter in the sidebar. Price range slider whose min/max are pulled from the actual cheapest and most-expensive product in the DB (not a hardcoded 0-100). Star rating filter (radios at 5★/4★/3★/2★/1★). Sort by latest / price / name. All filters are URL-shareable via query strings.
Product detail page — image gallery with zoom (supports multi-image galleries — drop
<slug>-2.jpegnext to<slug>.jpegand the seed picks it up). Quantity stepper. Add to cart. Wishlist toggle (signed-in only). Description / Additional info / Reviews tabs. Related products. Soft-404 with "did you mean…?" suggestions fromlib/product-suggest.js.Cart — quantity steppers, coupon apply (three coupons: ECO10, ECO20, FREE5), live totals. Cart persists to
localStoragefor guests; mirrored to the DB for signed-in users so it follows them across devices.Wishlist — signed-in only. Middleware redirects anonymous visitors to
/login?next=/wishlist.Checkout — Bangladeshi Division/District/Thana selects (not US zip codes), address prefill for signed-in users with saved addresses, guest checkout allowed (captures email/name/phone/address,
userIdis null on the order). Five payment methods (COD, PayPal, Amazon, bKash, Nagad — the schema fields exist; real gateway integration is TODO). Coupon apply. Thank-you screen with the order number.Deals landing pages (
/deals/<slug>) — one per promo banner. Shows only products whosebadgeortagsarray matches the banner'stargetTag. Copy-code control at the top.Order tracker (
/orders/lookup) — a guest enters their order number + email, gets the status. Signed-in users are redirected to/dashboard/ordersfor the full history.Multi-language plumbing (currently English-only; Bangla was removed but the plumbing stayed — one JSON file to re-add it).
Dark mode — cookie-based, no flash. The server reads the
ecobazar-themecookie and sets<html class="dark">before hydration.
Accounts & auth
Credentials sign-up / sign-in — pick a username, use email + password. Log in with either the username or the email (my auth handler lowercases the identifier and does
findFirst({where: {OR: [{username}, {email}]}})).Password reset flow — email a one-time token, expires in 1 hour.
Email verification — issued at signup but not enforced at login (design choice; enforcing it before people confirm the mail transport is set up would lock everyone out).
Optional Google + Facebook OAuth — mounts only when the client ID + secret env vars are both non-empty. If not, the buttons don't render and there are no warnings.
First-user-becomes-ADMIN — the very first person to sign up on a fresh install is auto-promoted to admin, marked as super-admin (undeletable/undemotable).
"Already signed in" guardrails — hitting
/loginor/registerwhile signed in shows a "you're already signed in as X" panel with a "continue to dashboard" link and a "sign out" link./unauthorizedbranches too: anonymous visitors see log-in / create-account CTAs; signed-in users with the wrong role see "you don't have access to this page" with a dashboard link. No misleading "please log in" for people who are already logged in.Guest → user order linking — this is a small but nice one. If a customer checks out as a guest, then later opens an account with the same email, all their prior guest orders are automatically attached to the new account on signup. They see their history in the dashboard on first login. Runs for both credentials and OAuth signups.
Order lifecycle
Server-side cart — Cart model, one row per user, keyed on
userId. On login, the local guest cart is merged into the saved cart. On logout, the local cart is cleared. On subsequent reloads, the DB is authoritative (no accidental double-counting).Guarded atomic stock decrement — the whole reason I need a replica set. More on this in §8.
Order status timeline — every status change (PENDING → PAID → SHIPPED → DELIVERED, or CANCELLED) is recorded as an append-only
OrderStatusEventwith a timestamp, actor, and optional note. The customer sees the timeline in the order details modal.Order details modal — customer and admin both see the item list (product, quantity, unit price, line total) + status timeline + totals block (subtotal, discount, shipping, total). Item names come from the DB but prices are snapshotted — a product price change tomorrow doesn't retroactively change what's shown in yesterday's order.
Terminal statuses — DELIVERED and CANCELLED are terminal. Neither admin nor moderator can un-cancel or un-deliver an order. The one allowed exception: a customer's own return.
15-day return window — if a customer's order is DELIVERED and less than 15 days have passed since the DELIVERED event, they see a "Request return" button in the order details. Clicking it (with confirmation) flips the order to CANCELLED, restocks the items back to inventory, writes a timeline event noting "Return requested by customer", appends an audit log row. After 15 days the button disappears; the order stays DELIVERED forever.
Write review after delivery — customer sees a "Write review" CTA per item on a delivered order, which expands into a star rating + text form. Submitting creates an approved
Reviewrow (schema-enforced unique per (product, user)) and recomputesProduct.ratingas the running average. If they already reviewed the product, the CTA is replaced by "You reviewed this".
Dashboard (role-based)
Role routing —
/dashboardreads the session role and rendersAdminDashboard,ModeratorDashboard, orCustomerDashboard. Same URL, three different pages.Orders management — everyone sees
/dashboard/ordersbut the data is scoped server-side: customers see only their own; moderators see all (read-only, no status change); admins see all with an inline status dropdown.Product management — moderator can create products but can only edit/delete products where
Product.createdById === user.id(enforced in the server action, not the schema). Admin can edit anything.Image uploads — three endpoints with different rules:
/api/uploadfor product images (admin/mod, 4 MB),/api/upload/avatarfor user avatars (any signed-in, 2 MB), and/api/upload/bannerfor promo banners (admin only, 6 MB). All validate the file magic bytes (not just MIME type), assign a hashed filename<timestamp>-<sha1>.<ext>, and pick the extension from the validated type so afilename="x.html"withContent-Type: image/pngcan't sneak into/publicand get served as HTML from my origin.User management (admin only) — promote/demote roles. Super-admin is untouchable.
Promo banners (admin only) — upload banner artwork, set placement (TOP / BELOW_LIST), set a
targetTag, get a/deals/<slug>landing page for free.Hot Deals offers (admin only) — pick a product, set a percentage off (1-90%), set an end time. While the offer is live, the discounted price is applied everywhere — shop grid, product page, cart, checkout. The
Product.pricefield is never rewritten, so expiry doesn't need a cleanup job.Profile change requests — for the two "recovery channel" fields (email + phone), a change goes into an approval queue instead of applying instantly. Admin approves or rejects.
Audit log (admin only) — every privileged write appends a row with actor, action name, entity, entity ID, and JSON metadata.
Settings page — profile (name, username, avatar), password change, saved addresses (with default flag), appearance toggle. Currency section was here — I removed it when I decided the store would be Taka-only.
Architecture — how the pieces fit together
Browser
│
▼
Next.js server (Node 22, PM2)
│
├─ App Router: server components render HTML on the server
├─ Server actions ("use server"): mutate data, called from client without a manual API
├─ Middleware (Edge): gates /dashboard/*, /wishlist* — auth-check only, no DB access
│
▼
Prisma Client
│
▼
MongoDB (single-node replica set on localhost)
Three enforcement layers for authorization. This is the load-bearing pattern:
Middleware (
middleware.js) — runs on the Edge. Checks signed in or not. Anonymous/dashboardvisitors bounce to/unauthorized; anonymous/wishlistvisitors bounce to/login. Middleware never touches Prisma or bcrypt (they can't run on the Edge runtime), it only reads the JWT.Server components / pages — call
requireAuth()orrequireRole()fromlib/auth-helpers.jsto enforce the actual role per route.Server actions & API routes — re-check the role again, even though the route is already protected. Defense in depth. Self-service actions read
session.user.idfrom the server session, never from client input.
Why three layers? Because layer #1 can only know "signed in or not" (no DB access on the Edge), layer #2 enforces the specific role per page but doesn't help if someone hits your action from the outside, and layer #3 is your only real backstop against a bug in either of the first two.
File extension convention. .js = server component or module. .jsx = client component ("use client" at the top). "use client" and "use server" are load-bearing.
Two data sources — don't confuse them.
lib/products-db.js— Prisma reads for customer pages (listProducts,getProductBySlug,queryProducts,getPriceBounds, etc.). Source of truth for the running app.prisma/seed-data.js— the seed catalogue (12 categories + 59 products). Consumed by bothprisma/seed.js(dev) andprisma/seed.prod.js(production). Not read at runtime by pages.
React hooks used — count + how each one is used
I use every core React hook except useLayoutEffect. Here's what each one is for in this codebase, with a short example.
useState — 149 uses
The workhorse. Anywhere I need local component state. Modals, form fields, dropdown open/close, current tab, current page number, filter values, error/notice messages.
const [open, setOpen] = useState(false);
const [rating, setRating] = useState(5);
const [error, setError] = useState(null);
useEffect — 30 uses
Anywhere I need to synchronize with something outside React — usually URL query strings, body scroll lock when a modal opens, or a debounced fetch for the shop's live filters.
// Debounced fetch of the current page from /api/products
useEffect(() => {
const id = setTimeout(async () => {
const res = await fetch(`/api/products?${params}`);
setItems((await res.json()).items);
}, 250);
return () => clearTimeout(id);
}, [query, activeCat, maxPrice, minRating, sort, page]);
useTransition — 36 uses
For calling server actions without blocking the UI. React 19 makes this pretty clean.
const [pending, startTransition] = useTransition();
startTransition(async () => {
const res = await submitReviewAction({ orderId, productId, rating, body });
if (!res.ok) setError(res.error);
else router.refresh();
});
The button's disabled={pending} and any inline spinner reads from pending.
useReducer — 2 uses
Just one important one: the cart. Cart state has many actions (add, remove, updateQty, setCoupon, clear, replace, merge) and living inside a useState object with lots of manual spread updates would be miserable. useReducer gives me one place where every mutation lives:
function cartReducer(state, action) {
switch (action.type) {
case "add": return { ...state, items: mergeItems(state.items, action.item) };
case "remove": return { ...state, items: state.items.filter(i => i.slug !== action.slug) };
case "updateQty": return { ...state, items: setQty(state.items, action.slug, action.qty) };
// …
}
}
useRef — 23 uses
For values that need to persist across renders without triggering a re-render. My favourite example is the "out-of-order fetch guard" on the shop page:
const reqId = useRef(0);
const mine = ++reqId.current;
const res = await fetch(...);
if (mine !== reqId.current) return; // a newer request superseded this one
setItems(await res.json());
Also used for the "first render seeded by server props, skip client fetch on mount" pattern:
const first = useRef(true);
useEffect(() => {
if (first.current) { first.current = false; return; }
// now fetch fresh
}, [query, ...]);
useMemo — 7 uses
Sparingly. Only when a computation is genuinely expensive AND runs on every render. In this codebase, I use it for filtering the current category list, resolving the theme's palette, and deriving product info from the cart items.
useCallback — 15 uses
Same principle as useMemo — sparingly. Mostly used when passing callbacks down to memoized child components, or to stabilize a function reference used as a useEffect dependency.
useContext — 8 uses (via 4 custom providers)
Four context providers wrap the app in app/layout.js:
ThemeProvider— dark mode.LanguageProvider— i18n (currently English-only).CurrencyProvider— currency display (currently pinned to BDT, but the plumbing is still there).CartProvider— the big one. Cart + wishlist + toast system.
Each has a hook: useTheme(), useT() (i18n — used 85 times), useCart() (22 uses).
const t = useT();
const { addItem, removeItem, items, coupon, toast } = useCart();
useRouter / usePathname / useSearchParams — 31 / 4 / 6 uses
From next/navigation. useRouter().refresh() re-runs the server component after a server action mutates data. usePathname() for highlighting the active nav item. useSearchParams() for reading URL state that the server also uses.
Authentication & authorization deep dive
Everything I know about auth I learned building this.
Sign-in flow (credentials)
User types their identifier (username OR email) + password on
/login.LoginFormcallssignIn("credentials", ...).NextAuth calls my
Credentials.authorize()inlib/auth.js:Normalize the identifier (
trim().toLowerCase()).Rate-limit check first, before bcrypt. Per-account: 10 attempts / 15 min. Per-IP: 30 attempts / 15 min. If tripped, return the same generic failure a wrong password would.
prisma.user.findFirst({where: {OR: [{username}, {email}]}}).bcrypt.compare(password, user.passwordHash).If ok: return
{id, email, name, image, role}.
NextAuth signs a JWT with those claims.
Subsequent requests read the JWT — no DB hit for role checks on a normal request.
Every 5 minutes (
ROLE_TTL_MS) the JWT re-verifies the role against the DB, so a demoted user's privileges expire within 5 min without needing to log them out.
Sessions (JWT, not DB)
JWT strategy, not SessionProvider. Session lives in a cookie. Sessions expire on inactivity: 6 hours for customers, 12 hours for admins/moderators (lib/session-policy.js). The JWT carries a rolling lastActivityAt; past the limit, the token's identity is stripped and the middleware treats it as anonymous.
Password hashing
bcrypt cost 12 at signup, reset, and seed. There's an inconsistency in the settings password-change flow (cost 10 there) that I need to fix.
Tokens
Password reset and email verification tokens are stored in VerificationToken with a composite identifier of <purpose>:<email>. Single-use, expire in 1 hour. Issuing a new one deletes the prior one so a user asking for two reset emails can't use both.
Authorization helpers
Every server component and every server action calls one of these from lib/auth-helpers.js:
export async function requireAuth(nextPath = "/dashboard") {
const user = await getCurrentUser();
if (!user) redirect(`/unauthorized?next=${encodeURIComponent(nextPath)}`);
return user;
}
export async function requireRole(roleOrRoles, nextPath = "/dashboard") {
const user = await requireAuth(nextPath);
const allowed = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];
if (!allowed.includes(user.role)) redirect("/unauthorized");
return user;
}
Usage:
// In a server action:
const actor = await requireRole(["ADMIN", "MODERATOR"], "/dashboard/orders");
// In a server component:
const user = await requireAuth("/dashboard/settings");
The checkout critical path (the scary one)
This is the one you cannot get wrong.
The problem: two customers hit "Place order" at exactly the same time, both wanting the last unit of Ilish fish. If I use naive read-then-write, both reads say stock=1, both writes decrement to stock=0, both orders succeed, and someone gets an angry email.
The fix: a guarded atomic update inside a prisma.$transaction. This is why the whole app needs a MongoDB replica set — Prisma's Mongo connector can only run transactions against replica sets.
await prisma.$transaction(async (tx) => {
for (const l of lines) {
const upd = await tx.product.updateMany({
where: { id: l.productId, stock: { gte: l.qty } }, // ← guard
data: { stock: { decrement: l.qty } },
});
if (upd.count === 0) {
throw new Error(`Out of stock: ${l.name}`); // → transaction rolls back
}
}
// ... create the order + items + PENDING status event
});
The magic is where: { stock: { gte: qty } }. If two writers race, MongoDB serializes them: whichever gets there first succeeds; the second one sees stock=0, its where clause doesn't match, count === 0, throws, transaction rolls back → no order, no stock change, cart is not cleared.
Other things the checkout does:
Prices are recomputed from the DB. The client sends only
{slug, qty}. If a malicious client tries to send{slug, qty, price: 1}for a৳1400Hilsa, the server ignores the price and uses the DB value. Anti-tampering by design.Live Hot Deals offers are applied during price recomputation, so the discounted price shown in the cart is what the customer is charged.
Line items are snapshotted.
OrderItemstoresproductNameandunitPriceas of the order — a product renamed or repriced tomorrow doesn't change yesterday's order.Guest checkout allowed —
userIdis nullable onOrder. Guests get a thank-you screen with their order number and can look it up at/orders/lookup.Order numbers are
ECO-+ 8 CSPRNG characters. Collision → retry.
Money — how integer math beat floating point
Rule: never store money in a float. 0.1 + 0.2 !== 0.3 in JavaScript, and after a few million multiplications you have real bugs.
MongoDB's Prisma connector doesn't have a Decimal type. So I store integer poisha (1/100 of a Taka). A ৳14.99 product is 1499 in the DB.
lib/money.js is the one place I convert:
export const toCents = (taka) => Math.round(Number(taka) * 100);
export const toDollars = (cents) => cents == null ? null : cents / 100;
export function formatMoney(cents) { /* returns "৳1,400.00" */ }
Rule of thumb: DB + server-side arithmetic in poisha; UI/forms/cart in Taka; only convert at the boundary.
The store is BDT-only. I had a multi-currency setup (BDT/USD/AED with admin-managed exchange rates and a CurrencyProvider), but it added complexity I didn't need — the shop is Bangladesh-only. I ripped out the switcher UI and made getActiveCurrency() return BDT unconditionally. The plumbing is dormant, not deleted — one file flips it back on if I ever need it.
Post-delivery flow — returns & reviews
Both of these are customer-callable server actions in app/dashboard/orders/_customer-actions.js, guarded by requireAuth() (not requireRole()).
The 15-day return window
lib/order-return.js has the eligibility check:
export function canRequestReturn({ viewerId, order, now }) {
if (!order) return { ok: false, reason: "notFound" };
if (order.userId !== viewerId) return { ok: false, reason: "notOwner" };
if (order.status !== "DELIVERED") return { ok: false, reason: "notDelivered" };
const deadline = returnDeadline(order.history, now);
if (!deadline) return { ok: false, reason: "notDelivered" };
if (now > deadline.getTime()) return { ok: false, reason: "windowClosed", deadline };
return { ok: true, deadline };
}
The deadline is deliveredAt + 15 days where deliveredAt is the first DELIVERED event on the order's timeline. Same helper is used in TWO places: the server component computes it once to decide whether to render the button; the server action re-checks it before mutating (defense in depth — never trust the client that it should be allowed).
When a customer clicks Request Return:
Confirm dialog on the client.
Server action re-validates via
canRequestReturn.In one transaction: flip
Order.statustoCANCELLED, restock all items via the samerestockCancelledOrderhelper that admin cancellations use, write anOrderStatusEventwith note"Return requested by customer"andactorId = user.id, append anAuditLogrow.revalidatePath("/dashboard/orders")so the UI updates without a hard refresh.
After the 15-day window closes, the button hides on the client (canReturn from the server is false), and the server action rejects with windowClosed if someone tries to call it via curl.
Reviews
Review.@@unique([productId, userId]) in the schema means a user can only review a given product once. My submitReviewAction:
if (order.userId !== user.id) return { ok: false, error: "This order isn't yours." };
if (order.status !== "DELIVERED") return { ok: false, error: "You can only review delivered orders." };
if (order.items.length === 0) return { ok: false, error: "That product wasn't in this order." };
const existing = await prisma.review.findUnique({
where: { productId_userId: { productId, userId: user.id } },
});
if (existing) return { ok: false, error: "You've already reviewed this product." };
// Create + recompute Product.rating as running average
After write, I recompute Product.rating as the average of all approved reviews, rounded to one decimal (matches the display format in ProductCard). Auto-approved for now — a moderation flow is a TODO.
Guest → user account: linking orders on signup
The nicest little feature. A customer checks out as a guest at /checkout with email asha@example.com. Order gets saved with userId: null and email: "asha@example.com".
Three weeks later, she decides to create an account with the same email. lib/user-service.js:
export async function claimGuestOrdersForUser(userId, email) {
if (!userId || !email) return 0;
try {
const res = await prisma.order.updateMany({
where: {
userId: null,
email: { equals: email, mode: "insensitive" },
},
data: { userId },
});
return res.count;
} catch {
return 0;
}
}
Called from BOTH the credentials signup route (app/api/auth/signup/route.js) and the OAuth createUser event (lib/auth.js). The mode: "insensitive" is important — if she typed ASHA@example.com at checkout but asha@example.com at signup, we still catch it. Never throws — a failure here doesn't block the signup response.
First login: she sees her three weeks' worth of guest orders in her dashboard. No support ticket, no manual link.
Deploying to a 1 GB VPS: what went wrong and how I fixed it
This section is the honest one.
I picked a small OpenVZ VPS — 1 GB RAM, 1 CPU, 9.8 GB disk, Ubuntu 24.04. Cheap, fine for a demo. Setup went smoothly:
Installed Node.js 22, PM2, MongoDB 8.0.
Configured MongoDB as a single-node replica set on
127.0.0.1:27017(no auth, localhost-only) with WiredTiger cache capped at 256 MB.Set up nginx as a reverse proxy on port 80, then upgraded to Let's Encrypt on 443 with certbot (once DNS pointed at the box).
Created a non-root
ecobazaruser, cloned the repo,npm ci,npm run build— first deploy worked.
Then I tried to redeploy. npm ci OOMed. Kernel killed random processes. Eventually killed sshd. Box became unreachable for 20 minutes until I could reboot from the provider's console.
Root cause: OpenVZ containers have a privvmpages limit that blocks fork() under memory pressure — even with visible free RAM in free -h. npm ci spawns dozens of processes for npm-cli-install-scripts. On a 1 GB container with MongoDB already using ~250 MB, the fork storm exceeds the limit. Plus swap is disallowed on OpenVZ (swapon returns "Operation not permitted").
Failed fix #1: stop MongoDB during npm install. Still OOMed — the fork limit isn't about free RAM, it's about virtual pages committed.
Failed fix #2: local build on Windows, ship .next/ and node_modules/ via tar. Two problems:
Windows npm creates
.bin/nextas a bash wrapper script, not a Linux symlink. When PM2 tried to run it, Node parsed it as JavaScript and threwSyntaxError: missing ) after argument list.Turbopack's built
.next/references@prisma/client-<hash>where the hash is computed against the localnode_moduleslayout. A Linuxnode_modulescomputes a different hash →Cannot find module '@prisma/client-2c3a283f134fdcb6'.
Both problems come from Windows and Linux producing different node_modules layouts. Local build → remote deploy only works if both machines match.
The real fix: build on GitHub Actions.
The GitHub Actions auto-deploy pipeline
.github/workflows/deploy.yml runs on every push to main:
ubuntu-24.04runner (matches VPS exactly).npm ci --legacy-peer-deps(the--legacy-peer-depsis because next-auth's beta declarespeerDependencies: next@^14||15; we're on next 16, works fine, npm just needs to be told to accept the mismatch).npm run build— runners have 7 GB RAM, no OOM risk. I set dummy env vars for build-time becauselib/prisma.jsthrows at import ifDATABASE_URLis malformed:env: DATABASE_URL: "mongodb://build.local:27017/build?replicaSet=rs0" NEXTAUTH_SECRET: "build-time-placeholder-not-used-at-runtime"Every route is dynamic (
ƒ), so no actual DB call fires during build. The URL just has to pass the shape check.npm prune --omit=dev— strip test tooling fromnode_modules.Install SSH key from
secrets.DEPLOY_SSH_KEY(with atr -d '\r'scrub because Windows-copied secrets sometimes have CRLF that breaks OpenSSH).Rsync
.next/+node_modules/+public/+ source tree to the VPS as theecobazaruser.-apreserves symlinks (critical for.bin/next).--delete-afteronly prunes removed files after a successful transfer — a mid-stream disconnect keeps the old tree intact.pm2 restart ecobazar --update-env(or first-time start with--max-memory-restart 500Mso a runaway worker triggers a controlled restart before it OOMs the box).Smoke test: curl
https://eco.shanto.dev/and expect HTTP 200 within 25 s. Job fails red if not.
Four repo secrets needed: DEPLOY_SSH_KEY, DEPLOY_KNOWN_HOSTS, DEPLOY_HOST, DEPLOY_USER.
I also added a nginx maintenance-page snippet: on 502/503/504 from the upstream, serve a friendly "we'll be right back" page instead of raw nginx errors. It sits dormant during normal operation and only appears when PM2 is down.
The wiring took SEVEN failed deploys — sed separators, SSH tilde expansion, set -e vs pipefail, missing -i deploy_key on the verify SSH — but the final pipeline runs in ~4-6 minutes per deploy and I haven't had to think about it since.
What I learned
Concrete technical stuff:
Server components are the default in Next 16. Client components (
"use client") are the exception, not the rule. Most of my pages ship near-zero JavaScript for their rendered HTML.Server actions (
"use server") let you call server code directly from client components without writing an API route. They're validated with Zod at the boundary and re-check auth every time.The three-layer authorization pattern (middleware → server component → server action) beats any single layer. If one has a bug, the others catch it.
Race conditions are subtle. A
where: { stock: { gte: qty } }predicate is the difference between "sometimes oversells" and "never oversells".Money is integer minor units, always.
1499not14.99.Client-tampering is a real thing. If your checkout accepts a price from the client, you have a bug.
Every image path from a form is a possible XSS vector. Validate MIME + magic bytes + pick the extension from the validated type, never from the filename.
Deploy stuff I didn't know before:
1 GB VPS is fine to run a Next.js app on. It is not fine to build on.
Windows and Linux produce different
node_modules. Shipping one to the other is a recipe for pain (bin-wrapper shells, Turbopack hash mismatches, Prisma engine mismatches).Build on the same OS as the runtime. GitHub Actions runners are the free easy way.
Let's Encrypt auto-renewal via
certbot.timeris a joy after ever setting up manual cert rotation.PM2
--max-memory-restart 500Mis a good guardrail when you're near the RAM ceiling.Nginx
error_page 502 503 504 = @maintenance;gives you a real "we'll be right back" page for the cost of two lines.
Meta stuff:
Writing docs as you go beats writing them at the end. I keep
README.md,DOCUMENTATION.mdandCLAUDE.mdin sync with the code. Any feature I add without a doc note is a feature future-me will forget how to change.Test what you fix. E2E tests broke every time I rebranded the catalogue, but they also caught the currency-symbol bug on the shop slider that I would have missed in prod.
The scariest bug is the one that fails silently.
rsync | tailwas silently swallowing rsync's non-zero exit for two failed deploys before I addedset -o pipefail.
What's next
Things I know are on the roadmap:
Payment integration — the schema has bKash, Nagad, PayPal, Amazon, COD fields. Only COD actually works. Wiring bKash next.
Review moderation UI — reviews auto-approve today. Admin approve/reject queue.
Real transactional email —
lib/mailer.jslogs to console. Swap for Resend/SES.Shared-store rate limiting — currently in-memory (
lib/rate-limit.js), so it doesn't coordinate across app instances. Move to Redis.Consolidate coupons — the coupon table is duplicated between
CartContext.jsxandorder-actions.js. One shared constant.TypeScript migration — probably. I'm ready.
Distinct
RETURNEDstatus — currently returns are recorded asCANCELLEDwith a note. A distinct enum value would be cleaner.
Wrapping up
This was my first full-stack project and I ended up with a real, deployed, working store — 16,055 lines of code, 137 source files, 19 database models, 26 routes, 18 E2E test files, and a story I can actually tell about every decision. It works today at eco.shanto.dev — go put something in the cart.
If you're building your first full-stack app: don't be afraid of the deploy step. The build I was so proud of on my laptop was totally different from the build that actually needs to run on a Linux box. Skip that lesson by building on GitHub Actions from day one.
If this write-up helped, share it, or drop a line at eco.shanto.dev/contact. And if you're a hiring manager reading this — hello 👋.
— Shanto

