Skip to main content

Command Palette

Search for a command to run...

Ecobazar Building My First Full-Stack E-Commerce Store (Next.js 16, MongoDB, NextAuth v5)

Updated
31 min readView as Markdown

A step-by-step teardown of a real Bangladeshi organic-grocery store I built from scratch.
#Live Demo
#Repo Link

This 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

  1. What is Ecobazar?

  2. The numbers, at a glance

  3. Full tech stack

  4. Feature list — everything I built

  5. Architecture — how the pieces fit together

  6. React hooks used — count + how each one is used

  7. Authentication & authorization deep dive

  8. The checkout critical path (the scary one)

  9. Money — how integer math beat floating point

  10. Post-delivery flow — returns & reviews

  11. Guest → user account: linking orders on signup

  12. Deploying to a 1 GB VPS: what went wrong and how I fixed it

  13. The GitHub Actions auto-deploy pipeline

  14. What I learned

  15. What's next


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 a tailwind.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 push for Mongo since it has no migrations), model definitions in prisma/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 500M so 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 push to main triggers 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)

  1. 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).

  2. Nav bar — Home · Shop · Pages · Track Order · About · Contact. "Track Order" wires to /orders/lookup, my guest tracker.

  3. 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.

  4. Product detail page — image gallery with zoom (supports multi-image galleries — drop <slug>-2.jpeg next to <slug>.jpeg and 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 from lib/product-suggest.js.

  5. Cart — quantity steppers, coupon apply (three coupons: ECO10, ECO20, FREE5), live totals. Cart persists to localStorage for guests; mirrored to the DB for signed-in users so it follows them across devices.

  6. Wishlistsigned-in only. Middleware redirects anonymous visitors to /login?next=/wishlist.

  7. 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, userId is 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.

  8. Deals landing pages (/deals/<slug>) — one per promo banner. Shows only products whose badge or tags array matches the banner's targetTag. Copy-code control at the top.

  9. Order tracker (/orders/lookup) — a guest enters their order number + email, gets the status. Signed-in users are redirected to /dashboard/orders for the full history.

  10. Multi-language plumbing (currently English-only; Bangla was removed but the plumbing stayed — one JSON file to re-add it).

  11. Dark mode — cookie-based, no flash. The server reads the ecobazar-theme cookie and sets <html class="dark"> before hydration.

Accounts & auth

  1. 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}]}})).

  2. Password reset flow — email a one-time token, expires in 1 hour.

  3. 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).

  4. 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.

  5. 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).

  6. "Already signed in" guardrails — hitting /login or /register while signed in shows a "you're already signed in as X" panel with a "continue to dashboard" link and a "sign out" link. /unauthorized branches 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.

  7. 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

  1. 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).

  2. Guarded atomic stock decrement — the whole reason I need a replica set. More on this in §8.

  3. Order status timeline — every status change (PENDING → PAID → SHIPPED → DELIVERED, or CANCELLED) is recorded as an append-only OrderStatusEvent with a timestamp, actor, and optional note. The customer sees the timeline in the order details modal.

  4. 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.

  5. 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.

  6. 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.

  7. 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 Review row (schema-enforced unique per (product, user)) and recomputes Product.rating as the running average. If they already reviewed the product, the CTA is replaced by "You reviewed this".

Dashboard (role-based)

  1. Role routing/dashboard reads the session role and renders AdminDashboard, ModeratorDashboard, or CustomerDashboard. Same URL, three different pages.

  2. Orders management — everyone sees /dashboard/orders but 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.

  3. 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.

  4. Image uploads — three endpoints with different rules: /api/upload for product images (admin/mod, 4 MB), /api/upload/avatar for user avatars (any signed-in, 2 MB), and /api/upload/banner for 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 a filename="x.html" with Content-Type: image/png can't sneak into /public and get served as HTML from my origin.

  5. User management (admin only) — promote/demote roles. Super-admin is untouchable.

  6. Promo banners (admin only) — upload banner artwork, set placement (TOP / BELOW_LIST), set a targetTag, get a /deals/<slug> landing page for free.

  7. 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.price field is never rewritten, so expiry doesn't need a cleanup job.

  8. 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.

  9. Audit log (admin only) — every privileged write appends a row with actor, action name, entity, entity ID, and JSON metadata.

  10. 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:

  1. Middleware (middleware.js) — runs on the Edge. Checks signed in or not. Anonymous /dashboard visitors bounce to /unauthorized; anonymous /wishlist visitors bounce to /login. Middleware never touches Prisma or bcrypt (they can't run on the Edge runtime), it only reads the JWT.

  2. Server components / pages — call requireAuth() or requireRole() from lib/auth-helpers.js to enforce the actual role per route.

  3. 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.id from 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 both prisma/seed.js (dev) and prisma/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)

  1. User types their identifier (username OR email) + password on /login.

  2. LoginForm calls signIn("credentials", ...).

  3. NextAuth calls my Credentials.authorize() in lib/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}.

  4. NextAuth signs a JWT with those claims.

  5. Subsequent requests read the JWT — no DB hit for role checks on a normal request.

  6. 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 ৳1400 Hilsa, 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. OrderItem stores productName and unitPrice as of the order — a product renamed or repriced tomorrow doesn't change yesterday's order.

  • Guest checkout alloweduserId is nullable on Order. 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:

  1. Confirm dialog on the client.

  2. Server action re-validates via canRequestReturn.

  3. In one transaction: flip Order.status to CANCELLED, restock all items via the same restockCancelledOrder helper that admin cancellations use, write an OrderStatusEvent with note "Return requested by customer" and actorId = user.id, append an AuditLog row.

  4. 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 ecobazar user, 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/next as a bash wrapper script, not a Linux symlink. When PM2 tried to run it, Node parsed it as JavaScript and threw SyntaxError: missing ) after argument list.

  • Turbopack's built .next/ references @prisma/client-<hash> where the hash is computed against the local node_modules layout. A Linux node_modules computes 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:

  1. ubuntu-24.04 runner (matches VPS exactly).

  2. npm ci --legacy-peer-deps (the --legacy-peer-deps is because next-auth's beta declares peerDependencies: next@^14||15; we're on next 16, works fine, npm just needs to be told to accept the mismatch).

  3. npm run build — runners have 7 GB RAM, no OOM risk. I set dummy env vars for build-time because lib/prisma.js throws at import if DATABASE_URL is 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.

  4. npm prune --omit=dev — strip test tooling from node_modules.

  5. Install SSH key from secrets.DEPLOY_SSH_KEY (with a tr -d '\r' scrub because Windows-copied secrets sometimes have CRLF that breaks OpenSSH).

  6. Rsync .next/ + node_modules/ + public/ + source tree to the VPS as the ecobazar user. -a preserves symlinks (critical for .bin/next). --delete-after only prunes removed files after a successful transfer — a mid-stream disconnect keeps the old tree intact.

  7. pm2 restart ecobazar --update-env (or first-time start with --max-memory-restart 500M so a runaway worker triggers a controlled restart before it OOMs the box).

  8. 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. 1499 not 14.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.timer is a joy after ever setting up manual cert rotation.

  • PM2 --max-memory-restart 500M is 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.md and CLAUDE.md in 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 | tail was silently swallowing rsync's non-zero exit for two failed deploys before I added set -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 emaillib/mailer.js logs 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.jsx and order-actions.js. One shared constant.

  • TypeScript migration — probably. I'm ready.

  • Distinct RETURNED status — currently returns are recorded as CANCELLED with 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