Capstone: Build a Secure Learning Platform
This capstone turns the earlier Next.js lessons into one product: a secure learning platform where instructors publish courses, learners enroll, lessons render quickly, and progress updates cannot be forged from the browser. The outcome is not a huge application. It is a small but complete architecture that proves you can place rendering, data access, authentication, authorization, mutation, caching, and verification in the right parts of the App Router.
The platform has four core user stories. A visitor can browse published courses. An authenticated learner can enroll and continue a lesson. An instructor can manage only courses they own. The system records progress once a learner completes a lesson. Each story is simple on the surface, but it crosses a server-client boundary where insecure capstones usually fail.
Platform Shape and Internal Flow
In the App Router, each route segment owns a piece of UI and can fetch data before React renders HTML. For this platform, public catalogue pages can be mostly server-rendered, while lesson pages combine a Server Component for protected content with a small Client Component for completion buttons and optimistic feedback. The important internal rule is that the browser may request an action, but it never decides whether the user is enrolled, which lesson belongs to which course, or which progress row should be written.
A typical lesson request flows through five checks. First, middleware or a server helper reads the session cookie and resolves the current user. Second, the page Server Component loads the course, lesson, enrollment, and instructor ownership records in one server-side query path. Third, the component chooses whether to render lesson content, a purchase or enrollment prompt, or a 404-style refusal. Fourth, mutations run through Server Actions or Route Handlers that validate input and re-check authorization. Fifth, the mutation invalidates the exact cached path or tag whose data changed.
Data Model Anatomy
A compact relational model works well because most rules are ownership and membership rules. A courses table stores id, slug, title, status, and instructorId. A lessons table stores courseId, slug, position, and content fields. An enrollments table connects userId and courseId. A lessonProgress table connects userId, lessonId, completedAt, and should have a uniqueness rule on userId plus lessonId.
That uniqueness rule is more than database tidiness. Completion buttons are easy to double-click, mobile networks retry requests, and React transitions can overlap. If progress completion is implemented as an idempotent upsert, repeated calls produce one durable completion record instead of duplicate progress.
Configuration and API Anatomy
The route tree can mirror the product. Use app/(marketing)/courses/[courseSlug]/page.tsx for public catalogue details, app/(learn)/learn/[courseSlug]/[lessonSlug]/page.tsx for protected learning, app/instructor/courses/[courseId]/page.tsx for authoring, and app/api/progress/route.ts or a Server Action for completion. The grouped segments keep layouts separate without changing URLs.
Use Server Components for course and lesson reads because they keep database clients, service tokens, and authorization joins out of the client bundle. Add "use client" only to controls that need local state, such as a completion button, a video player shell, or an editor field. Use cache: "no-store" or dynamic rendering for user-specific learning pages. Public course listings can be cached and revalidated when an instructor publishes a course.
Example 1: Authorizing Lesson Access
The first example is the core read path. It accepts route slugs, resolves the current user on the server, and returns a view model only when the learner is enrolled or owns the course as instructor. The deterministic behavior is that unauthorized users receive null, so the page can call notFound() or render an enrollment prompt without leaking lesson content.
type LessonAccess = {
userId: string | null;
courseSlug: string;
lessonSlug: string;
};
export async function getLessonForViewer(input: LessonAccess) {
const lesson = await db.lesson.findFirst({
where: {
slug: input.lessonSlug,
course: { slug: input.courseSlug, status: "PUBLISHED" }
},
select: {
id: true,
title: true,
bodyHtml: true,
course: { select: { id: true, instructorId: true } }
}
});
if (!lesson || !input.userId) return null;
const mayRead =
lesson.course.instructorId === input.userId ||
Boolean(await db.enrollment.findUnique({
where: { userId_courseId: { userId: input.userId, courseId: lesson.course.id } }
}));
return mayRead ? lesson : null;
}
The trade-off is one extra authorization query, unless your ORM can express the membership test inside the original query. Keeping the check server-side is non-negotiable: hiding content with a client condition would still ship data to the browser.
Example 2: Idempotent Progress Completion
The second example mutates state. It uses a Server Action, validates the lesson identifier, checks that the current user has an enrollment for the lesson’s course, and then upserts progress. Expected behavior: the first valid call creates progress, and a repeated valid call returns the same completed state without adding another row.
import { revalidatePath } from "next/cache";
import { z } from "zod";
const CompleteLessonInput = z.object({ lessonId: z.string().min(1) });
export async function completeLesson(rawInput: unknown) {
"use server";
const user = await requireUser();
const input = CompleteLessonInput.parse(rawInput);
const lesson = await db.lesson.findUnique({
where: { id: input.lessonId },
select: { id: true, slug: true, course: { select: { id: true, slug: true } } }
});
if (!lesson) throw new Error("lesson not found");
const enrollment = await db.enrollment.findUnique({
where: { userId_courseId: { userId: user.id, courseId: lesson.course.id } }
});
if (!enrollment) throw new Error("not enrolled");
await db.lessonProgress.upsert({
where: { userId_lessonId: { userId: user.id, lessonId: lesson.id } },
update: {},
create: { userId: user.id, lessonId: lesson.id, completedAt: new Date() }
});
revalidatePath(`/learn/${lesson.course.slug}/${lesson.slug}`);
return { completed: true };
}
This action deliberately does not accept a userId from the client. The user identity comes from the session. It also revalidates one learning path rather than clearing the whole site cache, which keeps unrelated public pages fast.
Example 3: A Client Boundary for Completion
The third example adds interactivity without moving authority into the browser. The Client Component manages pending UI and calls the server action. Expected behavior: while the request is in flight, the button is disabled; after success, the visible label becomes Completed.
"use client";
import { useTransition, useState } from "react";
import { completeLesson } from "./actions";
export function CompleteLessonButton({ lessonId }: { lessonId: string }) {
const [completed, setCompleted] = useState(false);
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending || completed}
onClick={() =>
startTransition(async () => {
const result = await completeLesson({ lessonId });
setCompleted(result.completed);
})
}
>
{completed ? "Completed" : pending ? "Saving..." : "Mark complete"}
</button>
);
}
This component is intentionally small. It receives only a lesson id and displays state. It cannot grant access, choose another user, or mark an unpublished lesson complete unless the server action also allows it.
Example 4: Deterministic Progress Math
Progress percentages should be computed from server-owned counts, not client-side lesson arrays that may be stale or filtered. The following standalone function shows the expected behavior for edge cases: a course with no lessons reports zero progress, and a learner with three completions in a ten-lesson course reports 30.
function progressPercent(completedLessons, totalLessons) {
if (!Number.isInteger(completedLessons) || !Number.isInteger(totalLessons)) {
throw new TypeError("lesson counts must be integers");
}
if (completedLessons < 0 || totalLessons < 0 || completedLessons > totalLessons) {
throw new RangeError("lesson counts are inconsistent");
}
if (totalLessons === 0) return 0;
return Math.round((completedLessons / totalLessons) * 100);
}
console.log(progressPercent(3, 10));
console.log(progressPercent(0, 0));
The output is:
30
0
Design Choices and Trade-offs
Server Actions are convenient for form-like mutations that are already coupled to a page. Route Handlers are better when mobile clients, webhooks, or external services need the same operation. For this capstone, either can work for progress, but do not implement the authorization rules twice with small differences. Put the shared rule in a server-only helper and call it from the chosen entry point.
Static rendering helps public course pages, but it is dangerous for learner-specific pages unless the page is explicitly dynamic. A cached lesson shell that includes one learner’s progress can expose stale or incorrect UI to another learner. Separate public course metadata from private progress state so each can use the right caching policy.
Rich text lesson content creates another trade-off. Storing sanitized HTML is fast to render, but every authoring path must sanitize consistently. Storing structured blocks is safer for rendering custom components, but it requires migration planning when block shapes change. In both cases, never trust instructor-authored content merely because instructors are authenticated.
Failure Modes and Troubleshooting
Symptom: a learner sees a lesson page but the completion button returns not enrolled. Cause: the read path checked course purchase by slug, while the mutation checked enrollment by course id in a different environment or seed dataset. Diagnose: log bounded identifiers such as user id, course id, lesson id, and deployment environment; compare the read query and mutation query. Correct: centralize the enrollment check and add an integration test that reads and completes the same lesson.
Symptom: progress occasionally exceeds 100 percent. Cause: duplicate progress rows were inserted during retries or double-clicks. Diagnose: query for duplicate userId and lessonId pairs, then reproduce with two simultaneous completion requests. Correct: add a unique database constraint and use an upsert or transaction.
Symptom: learners see another user’s progress after deployment. Cause: a user-specific page or fetch was cached as if it were public. Diagnose: inspect response headers, review fetch options, and test two accounts in separate browser contexts. Correct: mark the route dynamic or use no-store for user-specific reads, while keeping public catalogue data separately cacheable.
Security, Performance, and Reliability
The platform’s primary security boundary is the server-side authorization check performed at every read and mutation. Middleware can redirect unauthenticated users, but it should not be the only protection because direct action calls and route handlers still need their own checks. Validate all route params and request bodies, scope instructor queries by instructorId, and sanitize lesson content before display.
Performance comes from splitting data by audience. Cache public course lists, stream slow instructor dashboards with loading UI, and keep learner progress reads narrow. Reliability comes from idempotent mutations, database constraints, and exact revalidation. A completion event should be safe to retry; a publishing event should have a rollback path that returns a course to draft without deleting lessons.
Hands-on Lab
Prerequisites: a working Next.js App Router project, an authentication helper that can return the current user, a relational database or local mock with courses, lessons, enrollments, and progress, and a test runner capable of calling server-side functions.
- Create the four tables or mock collections described above. Add unique constraints for course slugs, lesson slugs within a course, enrollments by user and course, and progress by user and lesson.
- Seed one published course, two lessons, one enrolled learner, one unenrolled learner, and one instructor who owns the course.
- Implement
getLessonForViewerand verify that the enrolled learner and instructor receive lesson content while the unenrolled learner receivesnull. - Implement
completeLessonwith input validation, session-derived identity, enrollment checking, and an upsert. - Add the completion button to the lesson page. Keep the page content in a Server Component and the button in a Client Component.
- Run two completion requests for the same learner and lesson. Verify that only one progress row exists and that the UI still reports completion.
- Open the page as two different users in separate browser contexts. Verify that each user sees only their own progress.
- Cleanup by deleting seeded progress, enrollments, lessons, and courses, or roll back the database migration if this was a disposable branch.
Verification is complete when the protected read refuses the unenrolled learner, the mutation refuses forged or missing lesson ids, the duplicate completion test leaves one row, and the public course page still renders without requiring a session.
Assessment Exercises
- Move progress completion from a Server Action to a Route Handler. Which code should be shared so the security rule does not drift?
- A product manager wants public previews for the first lesson of every course. Describe the data and route changes that allow previews without weakening protection for the remaining lessons.
- Write a test that proves two rapid completion requests cannot create duplicate progress. What database constraint makes the test meaningful?
- Given a slow lesson page, decide which data can be cached publicly and which must remain per-user. Explain the consequence of caching the wrong part.
- An instructor changes a course from published to draft. List the paths or tags that should be revalidated and the user-visible result learners should see.
Summary
A secure learning platform in Next.js is built by keeping authority on the server, separating public course data from private learner state, and making progress mutations idempotent. The capstone proves that App Router architecture is not just file placement: it is a set of choices about where data is fetched, where identities are trusted, how cached output is invalidated, and how failures are corrected without leaking content or corrupting progress.
