Skip to content
Nextdemy

Nextdemy

A course marketplace with Razorpay checkout and a tracked record for every order, built as a Turborepo monorepo: Next.js, Express on Bun, shared Zod types.

Type

Web App

Role

Full-stack Developer

Built

Q4 2024

Updated

Q4 2026

Source

GitHub

Tech Stack

Next.js
TypeScript
Tailwind CSS
TanStack Query
Zustand
Shadcn UI
Motion.dev
Express.js
Bun
MongoDB
Zod
Razorpay
Turborepo
Docker

TL;DR: Nextdemy is a full-stack course platform where students buy and stream courses and instructors build courses and track revenue. It is a Turborepo monorepo with a Next.js 16 frontend, an Express API on Bun, MongoDB, Razorpay and Cloudinary, with Zod schemas shared between both apps. The hard parts were tracking every Razorpay order through pending, success and failed states after v1 enrolled a student twice for one payment, and keeping users logged in when a token refresh hit a sleeping free-tier server.

01

Enrolled twice for one payment

The first version of Nextdemy assumed a payment would be verified exactly once. A network timeout triggered a retry, and a student got enrolled twice: duplicate CourseProgress documents, duplicate confirmation emails. Working out what had happened was guesswork, because v1 had no record of where a payment was in its lifecycle.

Nextdemy is a course platform with two audiences. Students browse a catalog, pay through Razorpay, stream video, track progress per subsection and leave ratings. Instructors build courses from sections and subsections (each subsection holds a video), upload media through Cloudinary, and watch enrollments and revenue from a dashboard.

Rebuilding it meant working out where to draw module boundaries, how to keep a record of every payment, and what breaks when the server cold-starts in the middle of a token refresh.

02

Three workspaces and one schema package

Nextdemy is a Turborepo monorepo built with Bun: bun install at the root, Turbo runs the builds in parallel, and each app ships as a five-stage Docker build of about 30 lines. The API runs on Bun in production; the web image copies Next.js's standalone output onto a Node base image.

Of the three packages, shared-types matters most. Before it, I maintained parallel interfaces in each app, they drifted, and I found out from a 500 in production. Now both apps import the same schema:

packages/shared-types/src/course.ts
export const createCourseSchema = z.object({
  courseName: z.string(),
  courseDescription: z.string(),
  whatYouWillLearn: z.string(),
  price: z.coerce.number(),
  tag: z.string(),
  category: z.string(),
  instructions: z.string().optional(),
  status: z.string().optional(),
});
export type CreateCourseInput = z.infer<typeof createCourseSchema>;

A field rename is now a compile error in both codebases instead of a 500 at 2 AM.

On the frontend, state ownership is explicit. React Query holds everything that comes from the server: courses, profiles, payment history. Zustand holds everything client-only: the access token and the cart, both persisted to localStorage through Zustand's persist middleware. Nothing lives in both.

On the backend, the Express API is organized by domain instead of by technical layer. Each of the seven modules (auth, contact, course, health, payment, profile, upload) keeps its routes, controllers, services and Mongoose models together. Controllers validate input against the shared Zod schemas, delegate to services, and respond through a standardized ApiResponse utility. No controller calls res.status().json() directly. That rule comes from a previous project where two controllers formatted errors differently and the frontend had to parse both shapes defensively.

Middleware runs in a fixed order: rate limiting (250 requests per IP per 15 minutes), body parsing, CORS, Helmet, cookies, Morgan, mongo-sanitize, routes, a 404 handler, then a global error handler. Auth middleware sits at the route level, not globally. (I learned why after accidentally gating my health check and watching the load balancer panic.)

Errors split into two kinds:

shared/utils/api-error.ts
// Expected failures: typed, clean responses
throw ApiError.badRequest("Course name is required");
throw ApiError.unauthorized("Token expired");
throw ApiError.notFound("Course not found");

// Unexpected failures: logged via Pino, generic message to client
// Internal details never leak.

Expected failures return a typed ApiError envelope. Unexpected ones log the full trace through Pino and send the client a generic message. I added that rule after an unhandled Mongoose validation error put the entire document schema into a 500 response.

03

A Payment record for every order

Checkout is two requests to the API with Razorpay's checkout widget in between:

  1. capturePayment refuses the order if the student already owns any course in the cart, creates a Razorpay order, and saves a Payment document with status pending.
  2. The student pays in the Razorpay widget.
  3. verifyPayment recomputes an HMAC-SHA256 of order_id|payment_id with the Razorpay secret. A mismatch marks the payment failed with the reason "Invalid signature" and returns a 401. A match marks it success, stores the payment ID and signature, and enrolls the student: course roster, CourseProgress document, confirmation email through Resend.

v1 had none of the Payment bookkeeping. A retried verification ran the enrollment again, and debugging "where did my money go" meant guessing. Now every order has one document, razorpay_order_id has a unique index, and the status field says where each payment stopped.

The honest limitation: the record tracks each order's state, but it doesn't block a duplicate yet. verifyPayment doesn't check whether the order is already success before enrolling, and enrollment uses $push, so a second verify call for the same order would still add the student twice. The pre-checkout ownership check only stops a second order, not a second verification of the same one.

04

A token refresh that hit a sleeping server

I wrote the auth myself instead of using a library: bcrypt for password hashing, 15-minute access JWTs, 7-day refresh tokens in httpOnly cookies, OTP email verification, and role-based middleware for Student, Instructor and Admin.

An Axios interceptor connects the token flow to the rest of the app. When a request comes back 401, the interceptor queues every pending request, refreshes the token through the httpOnly cookie, and replays the queue without the user ever seeing the handshake.

That worked until the API went to sleep. The free-tier host (Render) sleeps the server after inactivity, and the first request back takes 2–3 seconds. When that first request was a token refresh, the frontend saw a 401, cleared the session, and logged the user out without a word.

The fix lives in the interceptor: if the refresh fails, it waits 2 seconds and tries once more, and every concurrent request waits in the queue during that window. Only a second failure clears the token and sends the user to the login page with a "Session expired" toast. It took about an hour, because I had written every layer of the chain and knew where to look.

05

Trade-offs

ChoseOverWhyCost
Hand-rolled auth (bcrypt, JWT, refresh cookie, OTP)An auth libraryI knew the full chain, which turned the cold-start logout into a one-hour fix.I own every layer of the auth code and its security.
A shared Zod schema packageParallel interfaces in each appDrift becomes a compile error instead of a 500 in production.A monorepo and Turborepo setup (about an afternoon).
A Payment document per order, unique on razorpay_order_idEnrolling straight from the verify call, as v1 didEvery order has a status and a failure reason to debug from.An extra write per checkout, and it records a duplicate verification without blocking it.
Seven domain modules (auth, contact, course, health, payment, profile, upload)Folders by technical layerEach feature's routes, controllers, services and models sit together.Cross-cutting code needs its own shared/ folder.
A single ApiResponse utilityres.status().json() in each controllerOne response shape for the frontend to parse.Every controller goes through the helper, with no shortcuts.
Free-tier hosting on RenderAn always-on serverIt was the free tier.2–3 second cold starts, and the silent-logout bug they caused.
06

Results

What shipped:

  • Student flow: catalog, Razorpay checkout with server-side HMAC verification, Cloudinary video streaming, per-subsection progress and ratings.
  • Instructor flow: a course builder with sections and subsections, Cloudinary media uploads, and an enrollment and revenue dashboard.
  • Three roles (Student, Instructor, Admin) enforced by route-level middleware.
  • A Payment document per Razorpay order with pending, success and failed states and a recorded failure reason.
  • 15-minute access tokens, 7-day refresh tokens, and a refresh interceptor that survives one failed attempt.
  • Three shared packages (shared-types, ui, typescript-config) consumed by both apps, each app Dockerized in about 30 lines.
  • Zero direct res.status().json() calls across the API's 11 controllers; every response goes through ApiResponse.
07

What I'd change

I'd finish the idempotency work. verifyPayment should flip the payment from pending to success in one conditional update (findOneAndUpdate on { razorpay_order_id, status: "pending" }) and enroll only if that update matched, with $addToSet instead of $push on the rosters. That closes the duplicate-verification gap that v1 taught me about.

The repo has a test script in its root package.json but no test files. The payment edge cases (signature failures, a duplicate verification, a browser closed mid-checkout leaving a pending order) are where I'd start.

The cold-start fix is a workaround: a 2-second retry in the interceptor hides a server that sleeps. Moving the API off a sleeping free tier would remove the problem.

08

FAQ

Q.01What stack is Nextdemy built with?

Nextdemy is a Turborepo monorepo with a Next.js 16 and React 19 frontend and an Express API running on Bun, backed by MongoDB with Mongoose. React Query owns server state, Zustand owns client-only state, Razorpay handles payments, Cloudinary serves video and images, and each app is Dockerized.

Q.02What can students and instructors do on Nextdemy?

Students on Nextdemy browse the course catalog, pay via Razorpay, stream video, track progress per subsection and leave ratings. Instructors build courses from sections and subsections, upload media through Cloudinary, and monitor enrollments and revenue from a dashboard.

Q.03How does Nextdemy verify Razorpay payments?

Nextdemy verifies every Razorpay payment server-side with an HMAC signature check before enrolling the student. Each order gets a Payment document that starts as pending and moves to success or failed (with a failure reason) alongside the Razorpay order, payment and signature IDs, and confirmation emails go out via Resend.

Q.04How does Nextdemy handle a payment verification that arrives twice?

In Nextdemy v1, a network timeout caused a retried payment verification that enrolled a student twice, with duplicate CourseProgress documents and emails. Nextdemy v2 creates one Payment document per Razorpay order under a unique index on razorpay_order_id and refuses a new order for a course the student already owns. The verify step doesn't yet check whether an order is already marked success, so a fully idempotent enrollment is still on the to-do list.

Q.05How does authentication work in Nextdemy?

Nextdemy uses hand-rolled auth: bcrypt password hashing, 15-minute access JWTs, 7-day refresh tokens in httpOnly cookies, OTP email verification, and role-based middleware for Student, Instructor and Admin. An Axios interceptor queues requests on a 401, refreshes the token, and replays the queue.

Q.06How does Nextdemy keep frontend and backend types in sync?

Nextdemy has a shared-types package of Zod schemas, such as CreateCourseInput, that both the Next.js app and the Express API import. A field rename becomes a compile error in both codebases instead of a 500 in production.

Up Next

VentureDen

Where founders pitch ideas, get a Gemini score on three axes, and get upvoted by the community

Web App