Frontend
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
Data fetching
Backend
Database
Cache
Video
Payments
Auth
Architecture
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-previewsno auth
- GetGET
/public/preview/:idno auth
- GetGET
/enrolled/content/:idenrollment checked
- QuestionPUT
/enrolled/question - AnswerPUT
/enrolled/answer - PutPUT
/enrolled/review/:id - GetVdoCipherOTPPOST
/video/getVdoCipherOTPshort-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-loginNextAuth
- Change PasswordPUT
/profile/change-password - Change User RolePUT
/admin/change-user-roleadmin 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/usersaggregated by month
- CoursesGET
/admin/courses
Data model
Five collections. Small, because most of the structure lives inside Course rather than beside it.
Coursedeeply embedded treeSections, 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 roleA 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 recordWritten 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, flatDeliberately 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 typeHero, 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
Challenges
Caching the catalog was easy; invalidating it was the actual problem
Problem
Course reads were cached in Redis. An instructor would edit a lesson, save successfully, and keep seeing the old version.
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
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.
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
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.
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,/enrolledmake 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
- 37
- API endpoints
- 05
- Mongoose models
- 02
- Duration (months)
counted
counted
counted
counted
Lessons
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.
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.
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.
Links
Related
Related work
The Smart Cart
- e-commerce
- real-time
- payments
- databases
Small sellers need a storefront they control without building one, and buyers need one place to shop across sellers with secure payment and direct contact with the seller.
The Care Nexus
- ai
- healthcare
- real-time
- international
Doctors lose clinical time to manual paperwork, patient records are scattered across visits, clinics run without operational insight, and English-only systems exclude a large share of patients.
Open to full-time remote roles and freelance work.