Nextdemy

Nextdemy

A monorepo-powered learning platform with real payments, real auth, and real content delivery

Type

Web App

Role

Full-stack Developer

Built

Q4 2024

Source

GitHub

Tech Stack

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

Why I Built This

Most EdTech codebases I'd seen were monoliths held together by duct tape โ€” tangled auth, payment flows with no observability, and frontend/backend types that drifted silently until something broke in production. I wanted to build one properly. Not to prove I could use the tech, but to prove I could make the hard calls: where to draw module boundaries, how to handle a payment webhook that fires twice, what breaks when your server cold-starts mid-token-refresh.

02

How It Works

Two audiences, two very different workflows. Students browse a course catalog, pay via Razorpay, stream video content, track progress per-subsection, and leave ratings. Instructors build courses through a structured editor โ€” sections contain subsections, each with a video URL โ€” upload media through Cloudinary, and monitor enrollments and revenue from a dedicated dashboard.

The architecture is a Turborepo monorepo with three workspaces:

Next.js 16 ยท React 19 ยท App Router
Express ยท Bun ยท Domain modules
Zod schemas consumed by both apps
shadcn/ui component library
Shared TSConfig presets

That shared-types package is the load-bearing wall. A field rename breaks both codebases at compile time, not in production at 2 AM.

State ownership is explicit. React Query handles everything from the server โ€” courses, profiles, payment history. Zustand handles everything client-only โ€” auth tokens in memory, cart persisted across reloads. No overlap. An Axios interceptor bridges them: on a 401, it queues pending requests, refreshes via httpOnly cookie, replays the queue. The user never sees the handshake.

03

Key Decisions

Rolling my own auth

Bcrypt for hashing. Short-lived JWTs in memory. Refresh tokens in httpOnly cookies. OTP email verification. Role-based middleware โ€” Student, Instructor, Admin. I wrote every layer instead of reaching for a library, and it paid off the first time something broke. (Cold starts on Render made the refresh endpoint take 3 seconds to wake. The Axios interceptor now retries with backoff and queues concurrent requests. That fix took an hour because I understood the full chain.)

Razorpay payments

HMAC signature verification, idempotent enrollment keyed on razorpay_order_id, CourseProgress creation, and confirmation emails via Resend. The Payment model tracks explicit status transitions โ€” pending, success, failed โ€” with the full Razorpay reference chain. I didn't build that tracing for fun. I built it after v1 had zero observability and debugging "where did my money go" was guesswork.

Shared Zod schemas

packages/shared-types/src/course.ts
export const createCourseInput = z.object({
  courseName: z.string().min(3),
  courseDescription: z.string().min(10),
  price: z.number().min(0),
  tag: z.array(z.string()),
  category: z.string(),
  instructions: z.array(z.string()),
});

export type CreateCourseInput = z.infer<typeof createCourseInput>;

Both apps import CreateCourseInput directly. Before this, I maintained parallel interfaces that drifted. I'd find out from a 500 in production. Now a breaking change is a compile error. Simplest decision, highest return.

Bun everywhere

Both apps run on Bun โ€” fast cold starts, native TypeScript, no dev build step. bun install at the root, Turbo orchestrates parallel builds, each app Dockerized with multi-stage builds (~30 lines per Dockerfile). One runtime, one package manager. The less context switching between tools, the faster I ship.

04

Backend Architecture

The Express API is organized by domain, not technical layer. Each module โ€” auth, course, payment, profile, upload โ€” co-locates its routes, controllers, services, and Mongoose models. Controllers validate input against shared Zod schemas, delegate to services, return through a standardized ApiResponse utility. No controller touches res.status().json() directly โ€” that inconsistency bit me in a previous project. Once two controllers format errors differently, every frontend dev writes defensive parsing forever.

Middleware runs in a deliberate order: rate limiting โ†’ body parsing โ†’ CORS โ†’ Helmet โ†’ cookies โ†’ Morgan โ†’ mongo-sanitize โ†’ routes โ†’ global error handler. Auth middleware lives at the route level, not globally. (I learned why after accidentally gating my health check and watching the load balancer panic.)

Error handling is discriminated:

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.

I added that "never leak" rule after a Mongoose validation error exposed the entire document schema to a user.

05

Challenges

Double enrollment on webhook retries

v1 trusted that Razorpay's webhook would fire exactly once. It doesn't. A network timeout triggered a retry, students got enrolled twice โ€” duplicate CourseProgress documents, duplicate emails. Fix: idempotent enrollment keyed on razorpay_order_id with a unique index. Simple in retrospect, invisible until it happened.

Silent logout on cold starts

Free-tier hosting sleeps the server after inactivity. First request back takes 2โ€“3 seconds. If that request is a token refresh, the frontend sees a 401, clears the session, and the user gets logged out silently. Fix: Axios interceptor retries with a 2-second delay and queues every concurrent request during the refresh window.

Leaked Mongoose schema to client

An unhandled Mongoose validation error surfaced the entire document schema in a 500 response. Fix: discriminated error handling โ€” expected failures return typed ApiError envelopes, unexpected errors log full traces via Pino but return generic messages. Internal details never reach the client.

06

What I Learned

Shared types are the highest-leverage thing you can add to a full-stack repo. The Turborepo setup took an afternoon. It's caught type drift every week since. Every full-stack project I build from now on starts with a shared schema package.

Payment integrations are straightforward until they aren't. The happy path is a day. Edge cases โ€” signature failures, duplicate deliveries, partial rollbacks, "user closed browser mid-checkout" โ€” are the rest of the week. Status tracking and idempotency aren't optional.

Architecture opinions compound. Domain-organized modules, discriminated error types, explicit state ownership, standardized API responses โ€” individually, each is a small decision. Together, they're the reason I can add a feature without re-reading the entire codebase.

Up Next

Finote โ€“ Master Your Finances

react-nativeexpotypescriptfirebasezod

Command Menu

Search pages, blog posts, projects, and more.