Skip to main content

Building a Course Platform with Next.js: Behind the Scenes of Sabaoon Academy

March 17, 2026

</>

Sabaoon Academy is a course platform I built with Next.js, and in this post I'm going to walk through every technical decision behind it. Not the polished marketing version — the actual architecture, the tradeoffs, and the code that makes it work.

Architecture Overview

The stack is intentionally simple:

  • Next.js 16 with App Router for the framework
  • MDX for lesson content
  • localStorage for progress tracking
  • Firebase for analytics and authentication
  • Vercel for deployment

No database. No CMS. No payment processor (yet). Every course is a directory of MDX files with a meta.json descriptor. This makes content authoring as simple as creating a new markdown file and deploying.

Course Structure

Each course lives in app/courses/posts/ as a directory:

app/courses/posts/
├── react-fundamentals/
   ├── meta.json
   ├── 01-introduction.mdx
   ├── 02-components-and-jsx.mdx
   ├── 03-state-and-props.mdx
   └── 04-hooks.mdx
├── typescript-essentials/
   ├── meta.json
   ├── 01-why-typescript.mdx
   └── ...

The meta.json file contains course-level metadata:

{
  "title": "React Fundamentals",
  "summary": "Learn React from scratch — components, state, props, hooks, and patterns.",
  "tags": "react, javascript, frontend",
  "level": "beginner",
  "publishedAt": "2026-01-15",
  "updatedAt": "2026-03-01"
}

The level field drives visual styling throughout the platform. Each level maps to a color: beginner is green (#16a34a), intermediate is yellow (#FB9600), advanced is red (#dc2626). Course cards use a --course-color CSS variable that changes the hover accent based on level.

Lessons are numbered MDX files. The number prefix determines the order. Frontmatter is minimal:

---
title: "Components and JSX"
summary: "How to create and compose React components using JSX syntax."
---

Content here...

The Utilities Layer

The app/courses/utils.ts file handles all course data operations:

// Memoized — safe to call multiple times per build
export function getCourses(): Course[] {
  // Read all directories in posts/
  // Parse each meta.json
  // Count lessons per course
  // Sort by publishedAt date
}

export function getCourse(slug: string): CourseWithLessons {
  // Read meta.json for the course
  // Read all MDX files, parse frontmatter
  // Return course metadata + ordered lesson list
}

export function getLesson(
  courseSlug: string,
  lessonSlug: string
): LessonContent {
  // Read the specific MDX file
  // Parse frontmatter and content
  // Extract quiz data from HTML comments
}

export function getCoursesByTag(tag: string): Course[] {
  // Case-insensitive tag matching
  // Returns all courses that include the tag
}

export function getAllCourseTags(): string[] {
  // Collect unique tags across all courses
  // Used for tag filter pages and sitemap generation
}

The parseTags function is a shared utility that splits a comma-separated tag string into a clean array. One function, used everywhere — blog and courses share the same tag parsing logic.

Memoization matters here because Next.js calls these functions multiple times during a build (for the course list page, individual course pages, tag pages, sitemap, etc.). Without memoization, you'd read and parse every MDX file dozens of times.

MDX Rendering

Lessons are MDX files rendered at build time. The [slug]/[lesson]/page.tsx dynamic route handles rendering:

// app/courses/[slug]/[lesson]/page.tsx
export default async function LessonPage({
  params,
}: {
  params: Promise<{ slug: string; lesson: string }>;
}) {
  const { slug, lesson: lessonSlug } = await params;
  const lesson = getLesson(slug, lessonSlug);
  const course = getCourse(slug);

  // Find previous and next lessons for navigation
  const lessonIndex = course.lessons.findIndex((l) => l.slug === lessonSlug);
  const prevLesson = course.lessons[lessonIndex - 1];
  const nextLesson = course.lessons[lessonIndex + 1];

  return (
    <article className="prose dark:prose-invert">
      <h1>{lesson.title}</h1>
      <CustomMDX source={lesson.content} />
      <LessonNavigation prev={prevLesson} next={nextLesson} slug={slug} />
    </article>
  );
}

Note the await params — Next.js 15+ requires this in dynamic routes. Miss it and you get a runtime error that's hard to debug.

Quiz System

Quizzes are embedded directly in MDX content as HTML comments:

Some lesson content explaining React hooks...

<!--quiz {"question": "What hook do you use for side effects?", "options": ["useState", "useEffect", "useRef", "useMemo"], "answer": 1} -->

More content after the quiz...

The quiz JSON is extracted during MDX parsing. A client component renders the quiz UI inline with the lesson content — a question, multiple choice options, and immediate feedback on selection. No backend needed; it's purely for self-assessment.

Why HTML comments? Because MDX renderers ignore them in the default output, so the quiz data doesn't leak into the prose. The custom MDX processing pipeline extracts the comment, parses the JSON, and injects a <Quiz /> client component in its place.

Progress Tracking with localStorage

Course progress uses localStorage. No backend, no accounts required. The app/lib/progress.ts module manages it:

interface CourseProgress {
  completedLessons: string[];
  lastAccessed: string;
  startedAt: string;
}

export function markLessonComplete(
  courseSlug: string,
  lessonSlug: string
): void {
  const progress = getProgress(courseSlug);
  if (!progress.completedLessons.includes(lessonSlug)) {
    progress.completedLessons.push(lessonSlug);
  }
  progress.lastAccessed = new Date().toISOString();
  localStorage.setItem(`course-${courseSlug}`, JSON.stringify(progress));
}

export function getCourseCompletion(courseSlug: string): number {
  const progress = getProgress(courseSlug);
  const course = getCourse(courseSlug);
  return progress.completedLessons.length / course.lessons.length;
}

export function isCourseComplete(courseSlug: string): boolean {
  return getCourseCompletion(courseSlug) === 1;
}

The CourseLessonList client component reads progress and renders checkmarks next to completed lessons. The ProgressBar component shows a visual completion percentage. Both are client components because they need localStorage.

Tradeoff: progress doesn't sync across devices. That's acceptable for a free platform. When I add user accounts, progress will move to a database with localStorage as a fallback for anonymous users.

Certificate Generation

When a user completes all lessons in a course, they can generate a certificate. The certificate system has two parts: generation and verification.

Generation creates a unique certificate ID, stores it in localStorage, and renders a certificate page with the user's name (entered at completion), course title, completion date, and a verification URL.

Verification uses a deterministic ID scheme so anyone with the certificate URL can verify it's valid. The /courses/certificate/[id] route checks the ID format and displays the certificate or a "not found" message.

The certificate is rendered as a styled page that looks good when printed or saved as PDF. No canvas manipulation or image generation — just well-designed HTML and CSS with @media print styles.

Dynamic OG Images

Every page on the site uses the /og endpoint for Open Graph images. It's a single route that generates images dynamically based on a title query parameter:

// app/og/route.tsx
import { ImageResponse } from "next/og";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get("title") ?? "Sabaoon Academy";

  return new ImageResponse(
    (
      <div
        style={{
          /* Layout and styling */
          display: "flex",
          background: "#111",
          color: "#fff",
          width: "100%",
          height: "100%",
        }}
      >
        <h1>{title}</h1>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

Each page sets its OG image in metadata:

export function generateMetadata({ params }) {
  const course = getCourse(params.slug);
  return {
    openGraph: {
      images: [`/og?title=${encodeURIComponent(course.title)}`],
    },
  };
}

One endpoint, every page gets a unique OG image. No static image files to manage.

Tag-Based Filtering

Courses and blog posts share a tag system. The TagPill component accepts a basePath prop — /blog/tag for blog posts, /courses/tag for courses — so one component handles both.

Tag pages at /courses/tag/[tag]/page.tsx include structured data for SEO:

const jsonLd = {
  "@context": "https://schema.org",
  "@type": "CollectionPage",
  name: `${tag} Courses`,
  hasPart: courses.map((course) => ({
    "@type": "Course",
    name: course.title,
    description: course.summary,
  })),
};

Tag matching is case-insensitive everywhere. This prevents the "React" vs "react" problem where slight differences in tag casing split your content into separate pages.

The course cards page adds another filtering layer: category filters (Web Dev, Design, Testing, Marketing, DevOps) that use regex patterns to match against course tags. These are defined in the CourseCards client component and provide a high-level way to browse without knowing specific tags.

Course Recommendations

Each course page shows recommended courses at the bottom. The recommendation logic is tag-based: find courses that share the most tags with the current course, excluding itself. Simple but effective — a React course recommends TypeScript and Next.js courses because they share frontend tags.

Deployment

The site deploys to Vercel via GitLab. Every push triggers a build. The build process:

  1. Reads all MDX files and meta.json descriptors
  2. Generates static pages for all courses, lessons, and tag pages
  3. Generates the sitemap (including blog tags and course tags)
  4. Generates the robots.txt

Build times stay under 30 seconds because the content is all filesystem-based — no external API calls during build. The memoized utility functions ensure each file is read once regardless of how many pages reference it.

Environment variables for Firebase (NEXT_PUBLIC_FIREBASE_*) are set in Vercel's dashboard. The .env.local file is gitignored and only exists for local development.

What I'd Do Differently

Add a database earlier. localStorage progress tracking was the right MVP choice, but syncing progress across devices is a top feature request. A simple Postgres database with Drizzle ORM would handle this without much complexity.

Use content collections. Managing MDX files manually works, but a content layer like Contentlayer (or a custom schema validator) would catch frontmatter errors at build time instead of runtime.

Plan for payments from day one. Retrofitting a payment system into a free platform is harder than building with one from the start. Even if everything is free initially, having the infrastructure for paid tiers saves refactoring later.

The platform serves thousands of learners with zero operational cost beyond Vercel's free tier. The architecture is simple enough that I can focus on content instead of infrastructure. That's the point — pick boring technology that gets out of your way and lets you ship what matters.

Recommended Posts