Skip to content

New reading notes on system design, software architecture, and AI engineering. Explore reading

← Projects

Dec 31, 2025 · 6 min read · 0 views

The LearnX

Learning management system

LearnX is a full-featured Learning Management System built with a modern TypeScript stack (Next.js + Node/Express) designed for selling secure, high-quality online courses. It offers the VdoCipher powered video protection, seamless Stripe integration for secure payments, structured course management, and real-time enrollment analytics. With a clean and multi layered architecture, LearnX ensures scalability, maintainability, and a smooth learning experience.

Timeline
2025-11 - 2025-12
Origin
Built from a tutorial, then extended
Role
Solo build. Followed a roadmap tutorial for the core, then extended it.

Overview

LearnX is a full-featured Learning Management System built to deliver a secure, scalable, and seamless experience for selling and consuming online courses. Developed with a modern TypeScript stack Next.js on the frontend and Node.js/Express on the backend, it ensures high performance, fast routing, and smooth interactions across all user roles.

The platform is optimized for both students and instructors, offering responsive interfaces, efficient data handling, and a robust API layer. With its reliable architecture and polished user experience, LearnX provides a professional environment for modern digital education.

Twenty-eight components, thirty-seven endpoints, and five Mongoose models. A deliberately small data model for the amount of behaviour sitting on it.

Problem

Problems to solve

  • Protecting paid video

    Premium course video is the product, but standard HTML video makes the underlying file straightforward to retrieve.

  • Delayed enrollment

    Paid access needs to be provisioned without a manual step between a successful payment and course enrollment.

  • Different role needs

    Students, instructors, and administrators need different views of the same course catalog and its operations.

  • Repeated catalog reads

    Course and profile data is read often, so the application needs a way to serve repeat reads without treating every request as a full database read.

The project addresses the operational and technical constraints involved in selling protected video courses without claiming production outcomes.

Goals

Goals for the build

  • Protected video delivery

    Course video that cannot be trivially downloaded, with the viewer identified on screen so a screen recording is traceable.

  • Enrollment without administration

    Payment confirms, access appears. No manual step between the two.

  • Three role-shaped views

    Student, instructor, and administrator surfaces over one catalog, with authorisation enforced server-side rather than by hiding routes.

  • Fast repeat reads

    Course and profile reads cached, because the catalog is read constantly and written rarely.

  • Instructor course management

    Instructors need to build a curriculum, upload lessons, and answer questions within the course experience.

  • Reliable learning access

    Students who pay for a course need access to appear automatically and remain tied to their account.

The goals cover the course business, the people using it, and the access flow that connects payment to learning.

Tech stack

Frontend

Next.jsTypeScript
CostServer components and the App Router were new to me, and the first fortnight was slower for it.

Data fetching

Redux Toolkit Query
CostTag invalidation is invisible until it is wrong, and a mistyped tag fails silently by simply not refetching.

Backend

ExpressTypeScript
CostExpress provides no validation, no security headers, and no rate limiting by default. None of the three were added.

Database

MongoDBMongoose
CostA course document carries its entire curriculum, so it grows without bound as content is added.

Cache

Redis
CostInvalidation on instructor edits had to be built afterwards, and the gap produced a real bug.

Video

VdoCipher
CostCourse video now depends on a third party. If they are down, the product is down.

Payments

Stripe
CostEnrollment depends on a webhook arriving. Payment succeeding and access appearing are two events, not one.

Auth

JWTNextAuth for social login
CostTwo identity paths, one credential-based and one federated, both converging on a single user record. Reconciling them is fiddly.

Architecture

BrowserNext.js clientRTK QueryVideo playerSocket.IO clientrealtimeAPI tierExpress APITypeScriptSocket.IO serverrealtime eventsDataRediscached readsMongoDB5 modelsThird partyStripeCloudinarySMTP relayVdoCipherDRM streamRESTwsOTPdirect stream
Request paths and where video does not travel

Open lessonCheck enrollmentMongoDBRequest OTPVdoCipherissues tokenRefusedno token issuedEncrypted stream + watermarkenrolleddirect to playernot enrolled
Protected playback, and what a copied link gets you

User actionbrowse, enroll, or updateNext.js frontendrender interfaceRedux Toolkit Querydispatch requestBackend APIroute and authoriseCheck Redis cachecached readCache hitreturn cached dataCache miss: query MongoDBfetch source dataUpdate Redisstore fresh responseUpdate state and renderresponse reaches userhitmiss
System data flow through cache and database

Technologies I work with day to day, from the frontend through the backend to deployment.

Features

Student

6 features

Server-rendered course previews with the curriculum visible before purchase

Video lessons locked until the course is purchased

Stripe checkout with access provisioned through webhooks

Enrollment history for purchased courses

DRM-protected video playback with viewer identity watermarking

Questions asked directly inside lessons, tied to the lesson context

Instructor

6 features

Curriculum builder for creating nested course structures

Video uploads managed through VdoCipher

Course thumbnails uploaded through Cloudinary

Course pricing, discounts, and course-level editing

Replies to student questions within lessons

Replies to student reviews

Administrator

5 features

Course approval before publication

User role management

Platform analytics over users, orders, and courses

Database-level aggregation for analytics

Editable hero copy, FAQ, and categories without a deploy

Platform

8 features

Secure user registration, activation, login, and logout

JWT-based authentication with refresh-token rotation

Role-Based Access Control for students, instructors, and administrators

Profile management with password and social-login support

Redis-backed caching for frequently read course and profile data

Transactional email for account and enrollment updates

Real-time notifications and dashboard updates

Scheduled analytics snapshots for reporting

API design

Courses

The route prefixes carry the authorisation model: /admin, /public, /enrolled. It is visible in the path rather than buried in a middleware chain, so reading the route file tells you who can reach what. The one endpoint that is neither is the video OTP, which is a capability grant rather than a resource.

  • CreatePOST/admin/create
  • PutPUT/admin/update/:id
  • List PreviewsGET/public/all-previews

    no auth

  • GetGET/public/preview/:id

    no auth

  • GetGET/enrolled/content/:id

    enrollment checked

  • QuestionPUT/enrolled/question
  • AnswerPUT/enrolled/answer
  • PutPUT/enrolled/review/:id
  • GetVdoCipherOTPPOST/video/getVdoCipherOTP

    short-lived token

Users and auth

Activation is its own endpoint rather than a flag flipped at registration, so an unverified account cannot enrol. Refresh-token rotation sits on a GET, which is the one route here I would move to POST: a token exchange changes server state and should not be safely repeatable by a prefetch.

  • Create AccountPOST/auth/register
  • ActivatePOST/auth/activate
  • LoginPOST/auth/login
  • Refresh SessionGET/auth/refresh-token
  • Login with Social AccountPOST/auth/social-login

    NextAuth

  • Change PasswordPUT/profile/change-password
  • Change User RolePUT/admin/change-user-role

    admin only

Orders and analytics

Four order endpoints for an entire commerce flow, because Stripe owns the hard parts. Analytics is three admin-only aggregations rather than a reporting layer, enough to answer what happened this week, and no more.

  • Create Payment IntentPOST/create-payment-intent
  • Process OrderPOST/process-order
  • List OrdersGET/admin/all-orders
  • UsersGET/admin/users

    aggregated by month

  • CoursesGET/admin/courses

Data model

Five collections. Small, because most of the structure lives inside Course rather than beside it.

Top-level collectionsUseridentity, role, enrollmentsOrderpurchase and payment recordCoursecatalog and curriculumCourse relationshipsReviewcourse rating and repliesNotificationuser-facing system eventLayouthero, FAQ, categoriesRediscache, not a collectionEmbedded in CourseCourseClublesson, video, resourcesQuestionlesson discussionAnswerthreaded replyLinklesson resourceVideo and course datanested document fieldsPersistenceMongoDB + Mongoosefive top-level collectionsenrollspurchases
LearnX data model and embedded course structure

Coursedeeply embedded tree

Sections, lessons, links, questions, and replies all live inside the course document. One read renders the whole page, which is what the read path needs.

The cost is unbounded growth. Every question a student asks makes the document bigger, and every read carries all of them. This is the decision most likely to need reversing.

Userreferenced courses, embedded role

A user holds references to enrolled courses rather than the courses holding their students. Enrollment lists are read per-user constantly and per-course rarely.

Orderimmutable record

Written once by the Stripe webhook and never updated. An order is the audit trail for access, so anything that mutates it is a bug.

Notificationreferenced, flat

Deliberately its own collection rather than embedded on the user. Notifications are written frequently and read in bulk, and embedding them would make every user read carry the whole history.

Layoutsingleton per type

Hero, FAQ, and categories as editable documents. One row per layout type, fetched by type. It exists so marketing copy does not require a deploy.

Key flows

Visit LearnXNext.js frontendBrowse and filter coursescatalog APIOpen course detailspreview and curriculumCheck purchase statusenrollment lookupCreate Stripe payment intentnot purchasedComplete checkoutStripe client flowCreate order and enrollmentMongoDBRequest playback tokenVdoCipher OTPStream protected lessonwatermark + DRMAsk or answer questionscourse discussionAdd reviewrating and commentUpdate cache and renderRedux / RTK Querynot purchased
Student discovery, purchase, and course access

Open admin dashboardprotected Next.js routeAuthenticate administratorJWT + Redis sessionCreate or edit coursecourse APIUpload thumbnail and lessonsCloudinary + MongoDBManage CourseClub contentvideos, links, questionsReview questions and repliesmoderation workflowReview and moderate ratingsreview APIPersist changesMongoDBRefresh affected cacheRedis invalidationPublish admin notificationSocket.IO + notification recordUpdate dashboardlive admin state
Administrator course management and notifications

Challenges

Caching the catalog was easy; invalidating it was the actual problem

Problem

What:

Course reads were cached in Redis. An instructor would edit a lesson, save successfully, and keep seeing the old version.

Why:

I built the cache first and the invalidation afterwards. In between, every write path was a potential stale-read bug, and the ones I missed were the paths I had not thought of as writes: replying to a question edits the course document.

How it was solved

Every handler that touches a course now invalidates its cache key, and the question and review handlers count as course writes because they mutate the course document.

The real lesson is about ordering, not about Redis. A cache added before its invalidation path is a bug with a delay on it.

Enrollment depends on a webhook that may never arrive

Problem

What:

Stripe confirms payment to the browser and, separately, to the server. Access is granted only by the second one. If the webhook is delayed or lost, the student has paid and has nothing.

Why:

The alternative is worse. Letting the client grant access means trusting a request the buyer controls, and that request can be replayed.

How it was solved

The order write is idempotent on the payment intent id, so a retried webhook cannot enrol twice or charge twice. Stripe retries on its own, which covers transient failures.

What remains uncovered is a webhook that never arrives at all. There is no reconciliation job polling for paid-but-unprovisioned orders, and there should be.

Two identity paths converging on one account

Problem

What:

Credential signup and social login both have to end at a single user record. A student who registers with an email and later signs in with Google must not become two accounts holding two sets of enrollments.

Why:

Duplicate identity on a paid product means someone paying twice, or paying once and losing access, the worst possible failure on a course they bought.

How it was solved

Email is the join key: social login looks up an existing user by verified email and links to it rather than creating a second record.

This works because both providers verify email. It would not hold for a provider that does not, and I have not handled that case.

Practices

  • Authorisation in the route prefix. /admin, /public, /enrolled make the access model readable from the route file rather than inferred from middleware order.
  • TypeScript across both halves. request and response shapes stated once and shared, rather than restated and allowed to drift.
  • Idempotent webhook handling. keyed on the payment intent, so retries are safe.
  • Video never proxied. the API authorises and issues a token; bytes go direct.
  • Short-lived playback tokens. a copied stream URL stops working almost immediately.

Missing, and worth naming: no rate limiting, no automated tests, and no reconciliation job for lost webhooks. The third is the one that could cost a real student real money.

Metrics

28
React components

counted

37
API endpoints

counted

05
Mongoose models

counted

02
Duration (months)

counted

Lessons

01

Caching the course catalog was straightforward. Invalidating it on instructor edits was the actual problem, and I built the invalidation path after the cache rather than alongside it, which is why replying to a question served a stale course for two days before I found it.

02

Embedding the whole curriculum in one document made the read path fast and the write path awkward. I optimised for the query I was looking at rather than for the shape the data would grow into, and a course document that accumulates every question ever asked is the result.

03

TypeScript on both halves paid for itself in the second month. The first two weeks were slower than the equivalent JavaScript build, and every week after that was faster, because the errors that used to appear at runtime appeared while I was typing instead.

Related

Open to full-time remote roles and freelance work.