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

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:
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:
// 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.
A Payment record for every order
Checkout is two requests to the API with Razorpay's checkout widget in between:
capturePaymentrefuses the order if the student already owns any course in the cart, creates a Razorpay order, and saves aPaymentdocument with statuspending.- The student pays in the Razorpay widget.
verifyPaymentrecomputes an HMAC-SHA256 oforder_id|payment_idwith the Razorpay secret. A mismatch marks the paymentfailedwith the reason "Invalid signature" and returns a 401. A match marks itsuccess, stores the payment ID and signature, and enrolls the student: course roster,CourseProgressdocument, 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.
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.
Trade-offs
| Chose | Over | Why | Cost |
|---|---|---|---|
| Hand-rolled auth (bcrypt, JWT, refresh cookie, OTP) | An auth library | I 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 package | Parallel interfaces in each app | Drift 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_id | Enrolling straight from the verify call, as v1 did | Every 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 layer | Each feature's routes, controllers, services and models sit together. | Cross-cutting code needs its own shared/ folder. |
A single ApiResponse utility | res.status().json() in each controller | One response shape for the frontend to parse. | Every controller goes through the helper, with no shortcuts. |
| Free-tier hosting on Render | An always-on server | It was the free tier. | 2–3 second cold starts, and the silent-logout bug they caused. |
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
Paymentdocument 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 throughApiResponse.
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.