Skip to content
~sami-ullah

Concept project

Mini Learning Management System

A concept project with a working demo: a small learning platform with courses, Markdown lessons, enrolment guards, idempotent progress tracking, server-graded quizzes and printable certificates, plus an instructor role that writes lessons and sees student progress. The demo runs in the browser and shows the NestJS requests and PostgreSQL queries behind every click.

Screenshot of the Mini Learning Management System demo

Overview#

A learning platform sounds like content plus a progress bar. The interesting parts are the rules around that content: who may read a lesson, what counts as "done", how a quiz is graded without leaking the answers, and when a certificate is earned.

This concept covers that core loop: publish, enrol, learn, test, certify. The live demo runs in your browser. A dev panel next to the app shows each API request, its status code and the PostgreSQL statements the real service would run, so you can check the backend behaviour as well as the screens.

My role#

I did all of it: the data model, the API rules, the demo interface, the seed courses and this write-up. It is a concept project, not client work. The students in the demo are made-up seed rows.

The problem#

A small LMS has a handful of rules that are easy to get slightly wrong:

  • Access. The course outline can be public, but lesson bodies and quizzes are only for enrolled students. Hiding a button is not enough; a direct request has to fail.
  • Progress. Students double-click. Networks retry. A completion must count once, however many times it arrives.
  • Changing content. Instructors unpublish lessons. Progress should stop counting a hidden lesson without deleting anyone's history.
  • Quizzes. If the answer key reaches the browser before submission, anyone with dev tools gets full marks.
  • Certificates. They should be issued exactly once, when the last requirement is met, and be verifiable by someone else.

Approach & architecture#

◆ Student completes a lesson
  1. Browserthen
  2. POST /lessons/:id/completionthen
  3. RolesGuardthen
  4. EnrolledGuardthen
  5. BEGIN
  1. BEGINthen
  2. INSERT completion ON CONFLICT DO NOTHINGthen
  3. progress querythen
  4. certificate checkthen
  5. COMMITthen
  6. 200
◆ Quiz
  1. GET /quizzes/:idthen
  2. questions without correct_indexthen
  3. student answers
  1. POST /quizzes/:id/attemptsthen
  2. ValidationPipethen
  3. grade on serverthen
  4. INSERT attemptthen
  5. results + correct answers

The demo's api.ts is written the way a NestJS service would be. Guards run before any handler code, request bodies go through a zod schema that plays the role of a validation pipe, and errors come back in Nest's { statusCode, error, message } shape. Each request runs on a copy of the in-memory tables, and the copy is kept only if the request succeeds. That is how the demo imitates a transaction rolling back.

Completions are idempotent by key, not by checking first. In the PostgreSQL design the completion table's primary key is the pair of ids, and the insert simply ignores a duplicate:

sql
CREATE TABLE completions (
  student_id   uuid NOT NULL REFERENCES users(id),
  lesson_id    uuid NOT NULL REFERENCES lessons(id),
  completed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (student_id, lesson_id)
);

INSERT INTO completions (student_id, lesson_id) VALUES ($1, $2)
ON CONFLICT (student_id, lesson_id) DO NOTHING;

A "check, then insert" in application code has a race window between two requests. The key does not.

Progress only counts published lessons. The demo uses the same definition the SQL would:

ts
export function progressOf(db: DB, studentId: string, courseId: string) {
  const published = db.lessons.filter((l) => l.courseId === courseId && l.published);
  const ids = new Set(published.map((l) => l.id));
  const done = db.completions.filter((c) => c.studentId === studentId && ids.has(c.lessonId)).length;
  return { done, total: published.length, percent: published.length ? Math.round((done / published.length) * 100) : 0 };
}

Unpublishing a lesson removes it from both sides of the fraction, and the completion rows stay in place, so publishing it again restores everyone's history.

The quiz endpoint never selects the answer. GET /quizzes/:id maps each question to { id, prompt, options }. Grading happens on submit, and only then does the response include correctIndex for each question. If an attempt passes and it was the last requirement, the same transaction inserts a certificate with ON CONFLICT (student_id, course_id) DO NOTHING, so it is issued once.

Key challenges#

  • Guards in the right order. A student asking for an unpublished lesson gets 404, not 403. Telling them "forbidden" would confirm that the hidden lesson exists. Enrolment is checked only after the lesson is known to be visible.
  • Validation that explains itself. A quiz body with a missing answer or an out-of-range option is rejected with 400 before any write. The lesson editor shows the pipe's messages next to the form, field by field.
  • Safe lesson content. Lessons are Markdown written by instructors. The demo parses a small subset into plain data and renders React elements from it, so a <script> in a lesson is shown as text. Nothing goes through dangerouslySetInnerHTML.
  • Certificate codes. Codes are a hash of student, course and issue time, not a sequential id, so they cannot be guessed by counting. A public GET /certificates/:code endpoint lets anyone verify one.
  • Hydration. The seed uses timestamps, so it is created only after mount, and the server render shows a loading skeleton instead.

Tech stack#

  • Demo: TypeScript, React and Next.js. The API layer (api.ts), tables and seed data (db.ts) and the Markdown parser are plain TypeScript modules with no React. zod validates request bodies. State is saved in localStorage.
  • Production design: NestJS modules for courses, enrolments, progress and quizzes, with Passport JWT for login and role guards; PostgreSQL with the keys and constraints shown above; Next.js for a statically rendered catalogue and server-rendered lesson pages.

Results#

  • A working demo of the whole loop: catalogue with search and tag filters, enrolment, lessons, progress, quizzes, certificates and an instructor view with publish controls and a per-student report.
  • The double-click test sends the completion request twice. The dev panel shows INSERT 0 1 followed by INSERT 0 0, and progress moves once.
  • I checked the API module with a throwaway Vitest script: 403 for a lesson in a course you have not joined, no answer key in the quiz response, 400 for malformed quiz bodies, one completion row after two requests, and a certificate after the final quiz. Every seeded certificate holder is at 100%.

This is a concept build without real users, so there are no usage or performance numbers.

Try the demo#

Open the mini LMS demo. Things to try:

  1. Open "SQL basics" and click a lesson before enrolling. You get the 403 from the enrolment guard.
  2. In "Intro to Git", finish the lessons with Mark complete, pass both quizzes and print your certificate.
  3. Open a quiz with the dev panel showing and expand the response body: there are no correct answers in it.
  4. Switch to instructor, unpublish a lesson and watch every student's percentage change in the report.

What I would do next#

  • Move api.ts behind real NestJS controllers with Supertest tests against a PostgreSQL test database.
  • Add sections and quizzes to the instructor editor, with drag-to-reorder lessons.
  • Render certificates as server-side PDFs and store them, so the verification page can link to the original file.
Full screenshot of the Mini Learning Management System demo running in the browser
Screenshot of the live demo with its sample data. Click to open it.
▶ live demoEnrol, finish lessons, pass server-graded quizzes for a certificate, then switch to instructor and publish a lesson.

Skills used

  • Screenshot of Clinic Booking Systemlive demo

    Concept project

    Clinic Booking System

    Book a slot in your time zone, race two bookings for one slot, then drag appointments in the admin calendar and watch overlaps fail.

    TypeScriptReact.jsNext.jszod
    ▶ Open demo
  • Screenshot of Inventory Management Systemlive demo

    Concept project

    Inventory Management System

    Oversell a product, run a transfer that fails halfway and rolls back, receive a purchase order and export the low-stock list.

    TypeScriptReact.jsNext.jszod
    ▶ Open demo

#nextjs6 min

JSON-LD Structured Data in Next.js 15

How to add JSON-LD structured data in Next.js 15: a safe script component, typed schema-dts graphs with stable @id values, FAQ rules and how to test it.

read →