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.

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