Back to blog

Build a Video Platform: Public Course Catalog & SEO

javaspring-bootreactnextjsvideo-streaming
Build a Video Platform: Public Course Catalog & SEO

We have a working platform — courses, videos, streaming, subscriptions. But users can't discover it. There's no public-facing catalog, no SEO, no way for Google to index our courses. In this post, we'll build the storefront: a server-rendered course catalog that ranks in search engines and converts visitors into subscribers.

The key idea: the catalog is a marketing tool. Every course page needs to rank on Google, convince visitors that the content is worth paying for (with free preview lessons), and funnel them into the subscription flow. We'll use Next.js server components for fast initial load and SEO, JSON-LD for rich search results, and a clean UI that sells.

Time commitment: 3–4 hours
Prerequisites: Phase 9: Stripe Subscription Integration

What we'll build in this post:
✅ Public course catalog with responsive grid layout
✅ Server-rendered course detail pages with full SEO
generateMetadata() for dynamic Open Graph tags
✅ JSON-LD structured data for rich Google results
✅ Free preview lessons for non-subscribers
✅ Enrollment flow: browse → preview → subscribe → watch
✅ Course progress indicators for logged-in users


Public API Endpoints

Before building the frontend, we need public (unauthenticated) API endpoints that serve course data without requiring a JWT token.

PublicCourseController

// src/main/java/com/videoplatform/api/controller/PublicCourseController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.PublicCourseDetailResponse;
import com.videoplatform.api.dto.response.PublicCourseListResponse;
import com.videoplatform.api.service.PublicCourseService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
 
import java.util.List;
 
@RestController
@RequestMapping("/api/public/courses")
public class PublicCourseController {
 
    private final PublicCourseService courseService;
 
    public PublicCourseController(PublicCourseService courseService) {
        this.courseService = courseService;
    }
 
    @GetMapping
    public ResponseEntity<ApiResponse<List<PublicCourseListResponse>>> listCourses(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "12") int size) {
 
        var courses = courseService.getPublishedCourses(page, size);
        return ResponseEntity.ok(ApiResponse.success(courses));
    }
 
    @GetMapping("/{slug}")
    public ResponseEntity<ApiResponse<PublicCourseDetailResponse>> getCourse(
            @PathVariable String slug) {
 
        var course = courseService.getCourseBySlug(slug);
        return ResponseEntity.ok(ApiResponse.success(course));
    }
 
    @GetMapping("/featured")
    public ResponseEntity<ApiResponse<List<PublicCourseListResponse>>> getFeaturedCourses() {
        var courses = courseService.getFeaturedCourses();
        return ResponseEntity.ok(ApiResponse.success(courses));
    }
}

Public DTOs

// src/main/java/com/videoplatform/api/dto/response/PublicCourseListResponse.java
package com.videoplatform.api.dto.response;
 
public record PublicCourseListResponse(
    Long id,
    String title,
    String slug,
    String description,
    String thumbnailUrl,
    String difficulty,
    int totalLessons,
    int totalDurationMinutes,
    int enrolledCount
) {}
// src/main/java/com/videoplatform/api/dto/response/PublicCourseDetailResponse.java
package com.videoplatform.api.dto.response;
 
import java.util.List;
 
public record PublicCourseDetailResponse(
    Long id,
    String title,
    String slug,
    String description,
    String longDescription,
    String thumbnailUrl,
    String difficulty,
    int totalLessons,
    int totalDurationMinutes,
    int enrolledCount,
    List<SectionDetail> sections
) {
    public record SectionDetail(
        Long id,
        String title,
        int sortOrder,
        List<LessonPreview> lessons
    ) {}
 
    public record LessonPreview(
        Long id,
        String title,
        int durationSeconds,
        boolean isFreePreview,
        int sortOrder
    ) {}
}

PublicCourseService

// src/main/java/com/videoplatform/api/service/PublicCourseService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.dto.response.PublicCourseDetailResponse;
import com.videoplatform.api.dto.response.PublicCourseListResponse;
import com.videoplatform.api.entity.Course;
import com.videoplatform.api.entity.CourseStatus;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.CourseRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.util.List;
 
@Service
public class PublicCourseService {
 
    private final CourseRepository courseRepository;
 
    public PublicCourseService(CourseRepository courseRepository) {
        this.courseRepository = courseRepository;
    }
 
    @Transactional(readOnly = true)
    public List<PublicCourseListResponse> getPublishedCourses(int page, int size) {
        var pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
 
        return courseRepository.findByStatus(CourseStatus.PUBLISHED, pageable)
                .stream()
                .map(this::toListResponse)
                .toList();
    }
 
    @Transactional(readOnly = true)
    public PublicCourseDetailResponse getCourseBySlug(String slug) {
        Course course = courseRepository.findBySlugAndStatus(slug, CourseStatus.PUBLISHED)
                .orElseThrow(() -> new ResourceNotFoundException("Course not found: " + slug));
 
        return toDetailResponse(course);
    }
 
    @Transactional(readOnly = true)
    public List<PublicCourseListResponse> getFeaturedCourses() {
        return courseRepository.findByStatusAndFeaturedTrue(CourseStatus.PUBLISHED)
                .stream()
                .map(this::toListResponse)
                .toList();
    }
 
    private PublicCourseListResponse toListResponse(Course course) {
        int totalLessons = course.getSections().stream()
                .mapToInt(s -> s.getLessons().size())
                .sum();
 
        int totalDuration = course.getSections().stream()
                .flatMap(s -> s.getLessons().stream())
                .mapToInt(l -> l.getDurationSeconds() != null ? l.getDurationSeconds() : 0)
                .sum();
 
        return new PublicCourseListResponse(
                course.getId(),
                course.getTitle(),
                course.getSlug(),
                course.getDescription(),
                course.getThumbnailUrl(),
                course.getDifficulty().name(),
                totalLessons,
                totalDuration / 60,
                course.getEnrolledCount()
        );
    }
 
    private PublicCourseDetailResponse toDetailResponse(Course course) {
        var sections = course.getSections().stream()
                .sorted((a, b) -> a.getSortOrder() - b.getSortOrder())
                .map(section -> {
                    var lessons = section.getLessons().stream()
                            .sorted((a, b) -> a.getSortOrder() - b.getSortOrder())
                            .map(lesson -> new PublicCourseDetailResponse.LessonPreview(
                                    lesson.getId(),
                                    lesson.getTitle(),
                                    lesson.getDurationSeconds() != null ? lesson.getDurationSeconds() : 0,
                                    lesson.isFreePreview(),
                                    lesson.getSortOrder()
                            ))
                            .toList();
 
                    return new PublicCourseDetailResponse.SectionDetail(
                            section.getId(),
                            section.getTitle(),
                            section.getSortOrder(),
                            lessons
                    );
                })
                .toList();
 
        int totalLessons = sections.stream()
                .mapToInt(s -> s.lessons().size())
                .sum();
 
        int totalDuration = sections.stream()
                .flatMap(s -> s.lessons().stream())
                .mapToInt(PublicCourseDetailResponse.LessonPreview::durationSeconds)
                .sum();
 
        return new PublicCourseDetailResponse(
                course.getId(),
                course.getTitle(),
                course.getSlug(),
                course.getDescription(),
                course.getLongDescription(),
                course.getThumbnailUrl(),
                course.getDifficulty().name(),
                totalLessons,
                totalDuration / 60,
                course.getEnrolledCount(),
                sections
        );
    }
}

Add Free Preview Flag to Lesson Entity

-- src/main/resources/db/migration/V6__add_free_preview_flag.sql
 
ALTER TABLE lessons ADD COLUMN is_free_preview BOOLEAN NOT NULL DEFAULT FALSE;
 
-- Make first lesson of each course a free preview by default
UPDATE lessons SET is_free_preview = TRUE
WHERE id IN (
    SELECT l.id FROM lessons l
    INNER JOIN sections s ON l.section_id = s.id
    WHERE s.sort_order = 0 AND l.sort_order = 0
);
// Add to Lesson entity
@Column(name = "is_free_preview", nullable = false)
private boolean freePreview = false;
 
public boolean isFreePreview() { return freePreview; }
public void setFreePreview(boolean freePreview) { this.freePreview = freePreview; }

Security Config Update

// In SecurityConfig.java — add public course endpoints
.requestMatchers("/api/public/**").permitAll()

Course Catalog Page

Data Fetching

// web/src/lib/api.ts — add public API functions
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
// Server-side fetch (no auth needed for public endpoints)
export async function getPublicCourses(page = 0, size = 12) {
  const res = await fetch(
    `${API_BASE}/api/public/courses?page=${page}&size=${size}`,
    { next: { revalidate: 300 } } // ISR: revalidate every 5 minutes
  );
 
  if (!res.ok) throw new Error("Failed to fetch courses");
  const data = await res.json();
  return data.data;
}
 
export async function getPublicCourse(slug: string) {
  const res = await fetch(`${API_BASE}/api/public/courses/${slug}`, {
    next: { revalidate: 300 },
  });
 
  if (!res.ok) {
    if (res.status === 404) return null;
    throw new Error("Failed to fetch course");
  }
  const data = await res.json();
  return data.data;
}
 
export async function getFeaturedCourses() {
  const res = await fetch(`${API_BASE}/api/public/courses/featured`, {
    next: { revalidate: 300 },
  });
 
  if (!res.ok) throw new Error("Failed to fetch featured courses");
  const data = await res.json();
  return data.data;
}

Types

// web/src/types/course.ts
 
export interface CourseListItem {
  id: number;
  title: string;
  slug: string;
  description: string;
  thumbnailUrl: string | null;
  difficulty: "BEGINNER" | "INTERMEDIATE" | "ADVANCED";
  totalLessons: number;
  totalDurationMinutes: number;
  enrolledCount: number;
}
 
export interface CourseDetail {
  id: number;
  title: string;
  slug: string;
  description: string;
  longDescription: string | null;
  thumbnailUrl: string | null;
  difficulty: "BEGINNER" | "INTERMEDIATE" | "ADVANCED";
  totalLessons: number;
  totalDurationMinutes: number;
  enrolledCount: number;
  sections: SectionDetail[];
}
 
export interface SectionDetail {
  id: number;
  title: string;
  sortOrder: number;
  lessons: LessonPreview[];
}
 
export interface LessonPreview {
  id: number;
  title: string;
  durationSeconds: number;
  isFreePreview: boolean;
  sortOrder: number;
}

Catalog Page (Server Component)

// web/src/app/(public)/courses/page.tsx
import { Metadata } from "next";
import { getPublicCourses } from "@/lib/api";
import { CourseCard } from "@/components/CourseCard";
import { CourseListItem } from "@/types/course";
 
export const metadata: Metadata = {
  title: "Course Catalog | VideoLearn",
  description:
    "Browse our collection of video courses. Learn programming, web development, and more with hands-on video tutorials.",
  openGraph: {
    title: "Course Catalog | VideoLearn",
    description: "Browse our collection of video courses.",
    type: "website",
  },
};
 
export default async function CourseCatalogPage() {
  const courses: CourseListItem[] = await getPublicCourses();
 
  return (
    <div className="max-w-7xl mx-auto px-4 py-12">
      <div className="text-center mb-12">
        <h1 className="text-4xl font-bold mb-4">Course Catalog</h1>
        <p className="text-xl text-muted-foreground max-w-2xl mx-auto">
          Practical, project-based video courses. Subscribe once, access everything.
        </p>
      </div>
 
      {courses.length === 0 ? (
        <div className="text-center py-16">
          <p className="text-muted-foreground text-lg">
            No courses available yet. Check back soon!
          </p>
        </div>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          {courses.map((course) => (
            <CourseCard key={course.id} course={course} />
          ))}
        </div>
      )}
    </div>
  );
}

CourseCard Component

// web/src/components/CourseCard.tsx
import Link from "next/link";
import Image from "next/image";
import { Clock, BookOpen, Users } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { CourseListItem } from "@/types/course";
 
const difficultyColors = {
  BEGINNER: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
  INTERMEDIATE: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
  ADVANCED: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
 
function formatDuration(minutes: number): string {
  const hours = Math.floor(minutes / 60);
  const mins = minutes % 60;
  if (hours === 0) return `${mins}m`;
  if (mins === 0) return `${hours}h`;
  return `${hours}h ${mins}m`;
}
 
export function CourseCard({ course }: { course: CourseListItem }) {
  return (
    <Link href={`/courses/${course.slug}`} className="group">
      <div className="rounded-xl border bg-card overflow-hidden transition-all
        hover:shadow-lg hover:border-primary/50 h-full flex flex-col">
        {/* Thumbnail */}
        <div className="relative aspect-video bg-muted">
          {course.thumbnailUrl ? (
            <Image
              src={course.thumbnailUrl}
              alt={course.title}
              fill
              className="object-cover group-hover:scale-105 transition-transform duration-300"
            />
          ) : (
            <div className="absolute inset-0 flex items-center justify-center
              bg-gradient-to-br from-primary/20 to-primary/5">
              <BookOpen className="h-12 w-12 text-primary/40" />
            </div>
          )}
          <Badge
            className={`absolute top-3 left-3 ${difficultyColors[course.difficulty]}`}
          >
            {course.difficulty.toLowerCase()}
          </Badge>
        </div>
 
        {/* Content */}
        <div className="p-5 flex flex-col flex-1">
          <h3 className="font-semibold text-lg mb-2 line-clamp-2 group-hover:text-primary
            transition-colors">
            {course.title}
          </h3>
          <p className="text-sm text-muted-foreground mb-4 line-clamp-2 flex-1">
            {course.description}
          </p>
 
          {/* Stats */}
          <div className="flex items-center gap-4 text-sm text-muted-foreground">
            <span className="flex items-center gap-1">
              <BookOpen className="h-4 w-4" />
              {course.totalLessons} lessons
            </span>
            <span className="flex items-center gap-1">
              <Clock className="h-4 w-4" />
              {formatDuration(course.totalDurationMinutes)}
            </span>
            <span className="flex items-center gap-1">
              <Users className="h-4 w-4" />
              {course.enrolledCount}
            </span>
          </div>
        </div>
      </div>
    </Link>
  );
}

Course Detail Page with SEO

Dynamic Metadata with generateMetadata()

// web/src/app/(public)/courses/[slug]/page.tsx
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPublicCourse, getPublicCourses } from "@/lib/api";
import { CourseSyllabus } from "@/components/CourseSyllabus";
import { CourseHero } from "@/components/CourseHero";
import { CourseJsonLd } from "@/components/CourseJsonLd";
import { CourseDetail } from "@/types/course";
 
interface Props {
  params: Promise<{ slug: string }>;
}
 
// Generate static paths for published courses
export async function generateStaticParams() {
  const courses = await getPublicCourses(0, 100);
  return courses.map((course: { slug: string }) => ({
    slug: course.slug,
  }));
}
 
// Dynamic metadata for each course
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const course: CourseDetail | null = await getPublicCourse(slug);
 
  if (!course) {
    return { title: "Course Not Found" };
  }
 
  const title = `${course.title} | VideoLearn`;
  const description = course.description;
  const url = `${process.env.NEXT_PUBLIC_SITE_URL}/courses/${slug}`;
 
  return {
    title,
    description,
    openGraph: {
      title,
      description,
      url,
      type: "website",
      images: course.thumbnailUrl
        ? [{ url: course.thumbnailUrl, width: 1200, height: 630 }]
        : undefined,
    },
    twitter: {
      card: "summary_large_image",
      title,
      description,
    },
    alternates: {
      canonical: url,
    },
  };
}
 
export default async function CourseDetailPage({ params }: Props) {
  const { slug } = await params;
  const course: CourseDetail | null = await getPublicCourse(slug);
 
  if (!course) notFound();
 
  return (
    <>
      <CourseJsonLd course={course} />
      <CourseHero course={course} />
      <div className="max-w-4xl mx-auto px-4 py-12">
        {/* Long description */}
        {course.longDescription && (
          <section className="mb-12">
            <h2 className="text-2xl font-bold mb-4">About This Course</h2>
            <div className="prose dark:prose-invert max-w-none">
              <p>{course.longDescription}</p>
            </div>
          </section>
        )}
 
        {/* Syllabus */}
        <section>
          <h2 className="text-2xl font-bold mb-6">Course Content</h2>
          <p className="text-muted-foreground mb-6">
            {course.totalLessons} lessons &middot;{" "}
            {Math.floor(course.totalDurationMinutes / 60)}h{" "}
            {course.totalDurationMinutes % 60}m total
          </p>
          <CourseSyllabus sections={course.sections} courseSlug={slug} />
        </section>
      </div>
    </>
  );
}

JSON-LD Structured Data

JSON-LD tells Google exactly what your page is about. For courses, it enables rich results showing ratings, price, and provider.

// web/src/components/CourseJsonLd.tsx
import { CourseDetail } from "@/types/course";
 
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
 
export function CourseJsonLd({ course }: { course: CourseDetail }) {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Course",
    name: course.title,
    description: course.description,
    url: `${SITE_URL}/courses/${course.slug}`,
    provider: {
      "@type": "Organization",
      name: "VideoLearn",
      sameAs: SITE_URL,
    },
    // Course details
    educationalLevel: course.difficulty.toLowerCase(),
    numberOfLessons: course.totalLessons,
    timeRequired: `PT${course.totalDurationMinutes}M`,
    // Thumbnail
    ...(course.thumbnailUrl && {
      image: course.thumbnailUrl,
    }),
    // Offer (subscription)
    offers: [
      {
        "@type": "Offer",
        category: "Subscription",
        price: "9.99",
        priceCurrency: "USD",
        availability: "https://schema.org/InStock",
        url: `${SITE_URL}/pricing`,
        description: "Monthly subscription — access all courses",
      },
      {
        "@type": "Offer",
        category: "Subscription",
        price: "99.99",
        priceCurrency: "USD",
        availability: "https://schema.org/InStock",
        url: `${SITE_URL}/pricing`,
        description: "Yearly subscription — access all courses (save 17%)",
      },
    ],
    // Syllabus as item list
    hasCourseInstance: {
      "@type": "CourseInstance",
      courseMode: "online",
      courseWorkload: `PT${course.totalDurationMinutes}M`,
    },
  };
 
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
    />
  );
}

What Google Sees

When Google crawls /courses/spring-boot-fundamentals, it finds:

{
  "@context": "https://schema.org",
  "@type": "Course",
  "name": "Spring Boot Fundamentals",
  "description": "Learn Spring Boot from scratch...",
  "provider": { "@type": "Organization", "name": "VideoLearn" },
  "educationalLevel": "beginner",
  "numberOfLessons": 24,
  "offers": [
    { "@type": "Offer", "price": "9.99", "priceCurrency": "USD" }
  ]
}

This can show as a rich result with course name, provider, price, and level directly in search results.


Course Hero Section

// web/src/components/CourseHero.tsx
import { Clock, BookOpen, Users, BarChart } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { CourseDetail } from "@/types/course";
import { SubscribeButton } from "./SubscribeButton";
 
const difficultyLabels = {
  BEGINNER: "Beginner",
  INTERMEDIATE: "Intermediate",
  ADVANCED: "Advanced",
};
 
export function CourseHero({ course }: { course: CourseDetail }) {
  const freePreviewCount = course.sections
    .flatMap((s) => s.lessons)
    .filter((l) => l.isFreePreview).length;
 
  return (
    <div className="bg-gradient-to-b from-primary/5 to-background border-b">
      <div className="max-w-4xl mx-auto px-4 py-12">
        <div className="flex flex-col gap-6">
          {/* Difficulty badge */}
          <Badge variant="outline" className="w-fit">
            <BarChart className="h-3 w-3 mr-1" />
            {difficultyLabels[course.difficulty]}
          </Badge>
 
          {/* Title */}
          <h1 className="text-4xl font-bold tracking-tight">
            {course.title}
          </h1>
 
          {/* Description */}
          <p className="text-xl text-muted-foreground max-w-2xl">
            {course.description}
          </p>
 
          {/* Stats row */}
          <div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
            <span className="flex items-center gap-1.5">
              <BookOpen className="h-4 w-4" />
              {course.totalLessons} lessons
            </span>
            <span className="flex items-center gap-1.5">
              <Clock className="h-4 w-4" />
              {Math.floor(course.totalDurationMinutes / 60)}h{" "}
              {course.totalDurationMinutes % 60}m
            </span>
            <span className="flex items-center gap-1.5">
              <Users className="h-4 w-4" />
              {course.enrolledCount} enrolled
            </span>
          </div>
 
          {/* CTA */}
          <div className="flex items-center gap-4 mt-2">
            <SubscribeButton />
            {freePreviewCount > 0 && (
              <span className="text-sm text-muted-foreground">
                {freePreviewCount} free preview lesson{freePreviewCount > 1 ? "s" : ""}
              </span>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

Course Syllabus with Free Previews

// web/src/components/CourseSyllabus.tsx
"use client";
 
import { useState } from "react";
import { ChevronDown, ChevronRight, Play, Lock, Eye } from "lucide-react";
import { SectionDetail, LessonPreview } from "@/types/course";
 
function formatTime(seconds: number): string {
  const mins = Math.floor(seconds / 60);
  const secs = seconds % 60;
  return `${mins}:${secs.toString().padStart(2, "0")}`;
}
 
interface Props {
  sections: SectionDetail[];
  courseSlug: string;
}
 
export function CourseSyllabus({ sections, courseSlug }: Props) {
  const [expandedSections, setExpandedSections] = useState<Set<number>>(
    // Expand first section by default
    new Set(sections.length > 0 ? [sections[0].id] : [])
  );
 
  const toggleSection = (sectionId: number) => {
    setExpandedSections((prev) => {
      const next = new Set(prev);
      if (next.has(sectionId)) {
        next.delete(sectionId);
      } else {
        next.add(sectionId);
      }
      return next;
    });
  };
 
  return (
    <div className="space-y-2">
      {sections.map((section) => {
        const isExpanded = expandedSections.has(section.id);
        const sectionDuration = section.lessons.reduce(
          (sum, l) => sum + l.durationSeconds, 0
        );
        const freeCount = section.lessons.filter((l) => l.isFreePreview).length;
 
        return (
          <div key={section.id} className="rounded-lg border overflow-hidden">
            {/* Section header */}
            <button
              onClick={() => toggleSection(section.id)}
              className="w-full flex items-center justify-between p-4
                hover:bg-muted/50 transition-colors text-left"
            >
              <div className="flex items-center gap-3">
                {isExpanded ? (
                  <ChevronDown className="h-5 w-5 shrink-0" />
                ) : (
                  <ChevronRight className="h-5 w-5 shrink-0" />
                )}
                <div>
                  <h3 className="font-medium">{section.title}</h3>
                  <p className="text-sm text-muted-foreground">
                    {section.lessons.length} lessons &middot;{" "}
                    {formatTime(sectionDuration)}
                    {freeCount > 0 && ` · ${freeCount} free`}
                  </p>
                </div>
              </div>
            </button>
 
            {/* Lesson list */}
            {isExpanded && (
              <div className="border-t">
                {section.lessons.map((lesson) => (
                  <LessonRow
                    key={lesson.id}
                    lesson={lesson}
                    courseSlug={courseSlug}
                  />
                ))}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}
 
function LessonRow({ lesson, courseSlug }: { lesson: LessonPreview; courseSlug: string }) {
  return (
    <div className="flex items-center justify-between px-4 py-3 hover:bg-muted/30
      transition-colors border-b last:border-b-0">
      <div className="flex items-center gap-3">
        {lesson.isFreePreview ? (
          <Eye className="h-4 w-4 text-primary shrink-0" />
        ) : (
          <Lock className="h-4 w-4 text-muted-foreground shrink-0" />
        )}
        <span className={lesson.isFreePreview ? "text-primary" : ""}>
          {lesson.title}
        </span>
        {lesson.isFreePreview && (
          <span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
            Free Preview
          </span>
        )}
      </div>
 
      <div className="flex items-center gap-3">
        <span className="text-sm text-muted-foreground">
          {formatTime(lesson.durationSeconds)}
        </span>
        {lesson.isFreePreview && (
          <a
            href={`/courses/${courseSlug}/lessons/${lesson.id}`}
            className="text-sm text-primary hover:underline flex items-center gap-1"
          >
            <Play className="h-3 w-3" />
            Watch
          </a>
        )}
      </div>
    </div>
  );
}

Subscribe Button

This button adapts based on authentication and subscription status:

// web/src/components/SubscribeButton.tsx
"use client";
 
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
export function SubscribeButton() {
  const router = useRouter();
  const [status, setStatus] = useState<"loading" | "guest" | "subscribed" | "unsubscribed">(
    "loading"
  );
 
  useEffect(() => {
    const token = localStorage.getItem("accessToken");
    if (!token) {
      setStatus("guest");
      return;
    }
 
    fetch(`${API_BASE}/api/subscriptions/current`, {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((res) => res.json())
      .then((data) => {
        setStatus(data.data ? "subscribed" : "unsubscribed");
      })
      .catch(() => setStatus("unsubscribed"));
  }, []);
 
  if (status === "loading") {
    return (
      <button disabled className="px-6 py-3 rounded-lg bg-primary/50 text-primary-foreground">
        Loading...
      </button>
    );
  }
 
  if (status === "subscribed") {
    return (
      <button
        onClick={() => router.push("/dashboard")}
        className="px-6 py-3 rounded-lg bg-primary text-primary-foreground
          hover:bg-primary/90 transition-colors font-medium"
      >
        Go to Dashboard
      </button>
    );
  }
 
  return (
    <button
      onClick={() => {
        if (status === "guest") {
          router.push("/login?redirect=/pricing");
        } else {
          router.push("/pricing");
        }
      }}
      className="px-6 py-3 rounded-lg bg-primary text-primary-foreground
        hover:bg-primary/90 transition-colors font-medium"
    >
      Subscribe to Watch
    </button>
  );
}

Enrollment Flow

Here's the complete user journey:

The funnel is: Discover → Preview → Subscribe → Watch. Free preview lessons are the key conversion tool — they let users judge content quality before committing.


Course Progress for Logged-In Users

For subscribers, show how much of the course they've completed:

// web/src/components/CourseProgress.tsx
"use client";
 
import { useEffect, useState } from "react";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
interface Props {
  courseId: number;
  totalLessons: number;
}
 
export function CourseProgress({ courseId, totalLessons }: Props) {
  const [completedCount, setCompletedCount] = useState<number | null>(null);
 
  useEffect(() => {
    const token = localStorage.getItem("accessToken");
    if (!token) return;
 
    fetch(`${API_BASE}/api/courses/${courseId}/progress`, {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((res) => res.json())
      .then((data) => setCompletedCount(data.data?.completedLessons ?? 0))
      .catch(() => {});
  }, [courseId]);
 
  if (completedCount === null || completedCount === 0) return null;
 
  const percentage = Math.round((completedCount / totalLessons) * 100);
 
  return (
    <div className="mt-4">
      <div className="flex items-center justify-between text-sm mb-1">
        <span className="text-muted-foreground">Your progress</span>
        <span className="font-medium">{percentage}%</span>
      </div>
      <div className="h-2 bg-muted rounded-full overflow-hidden">
        <div
          className="h-full bg-primary rounded-full transition-all duration-500"
          style={{ width: `${percentage}%` }}
        />
      </div>
      <p className="text-xs text-muted-foreground mt-1">
        {completedCount} of {totalLessons} lessons completed
      </p>
    </div>
  );
}

Progress API Endpoint

// Add to existing controller
@GetMapping("/api/courses/{courseId}/progress")
public ResponseEntity<ApiResponse<CourseProgressResponse>> getCourseProgress(
        @PathVariable Long courseId,
        @AuthenticationPrincipal UserDetails userDetails) {
 
    User user = findUser(userDetails);
    long completedLessons = progressRepository
            .countByUserAndLessonCourseSectionCourseIdAndCompletedTrue(user, courseId);
 
    return ResponseEntity.ok(ApiResponse.success(
            new CourseProgressResponse(completedLessons)
    ));
}
 
record CourseProgressResponse(long completedLessons) {}

Sitemap Generation

For SEO, generate a dynamic sitemap that includes all published courses:

// web/src/app/sitemap.ts
import { MetadataRoute } from "next";
 
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Static pages
  const staticPages: MetadataRoute.Sitemap = [
    { url: SITE_URL, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
    { url: `${SITE_URL}/courses`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 },
    { url: `${SITE_URL}/pricing`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 },
  ];
 
  // Dynamic course pages
  try {
    const res = await fetch(`${API_BASE}/api/public/courses?page=0&size=100`);
    const data = await res.json();
    const courses = data.data || [];
 
    const coursePages: MetadataRoute.Sitemap = courses.map(
      (course: { slug: string }) => ({
        url: `${SITE_URL}/courses/${course.slug}`,
        lastModified: new Date(),
        changeFrequency: "weekly" as const,
        priority: 0.7,
      })
    );
 
    return [...staticPages, ...coursePages];
  } catch {
    return staticPages;
  }
}

Testing

1. Verify Public API

# List published courses
curl -s http://localhost:8080/api/public/courses | jq
 
# Get course by slug
curl -s http://localhost:8080/api/public/courses/spring-boot-fundamentals | jq
 
# Verify free preview flag
curl -s http://localhost:8080/api/public/courses/spring-boot-fundamentals | \
  jq '.data.sections[].lessons[] | select(.isFreePreview == true)'

2. Test SEO Output

# Check meta tags
curl -s http://localhost:3000/courses/spring-boot-fundamentals | \
  grep -E '<meta|<script type="application/ld\+json"' | head -20
 
# Validate JSON-LD
# Copy the JSON-LD from the page source and paste into:
# https://validator.schema.org/

3. Test Sitemap

curl -s http://localhost:3000/sitemap.xml
# Should include /courses/spring-boot-fundamentals

4. Test Enrollment Flow

  1. Open /courses/spring-boot-fundamentals in incognito
  2. Verify: course info visible, lessons show lock icons, free previews show "Watch" link
  3. Click "Subscribe to Watch" → should redirect to login
  4. Log in → should redirect to pricing
  5. Subscribe → should get access to all lessons

5. Lighthouse SEO Audit

Run Lighthouse on a course page:

npx lighthouse http://localhost:3000/courses/spring-boot-fundamentals \
  --only-categories=seo --output=json | jq '.categories.seo.score'
# Target: 0.9+

Common Mistakes

1. Client-Side Data Fetching for SEO Pages

// WRONG — Google can't see this content
"use client";
export default function CoursePage() {
  const [course, setCourse] = useState(null);
  useEffect(() => { fetchCourse().then(setCourse); }, []);
  return <div>{course?.title}</div>;
}
 
// RIGHT — server component, content in initial HTML
export default async function CoursePage({ params }: Props) {
  const course = await getPublicCourse(params.slug);
  return <div>{course.title}</div>;
}

Google's crawler executes JavaScript, but server-rendered content is indexed faster and more reliably.

2. Missing generateMetadata()

Without generateMetadata(), all course pages share the same generic meta tags. Each course needs unique title, description, and Open Graph image for social sharing.

3. Blocking the Entire Page Behind Auth

// WRONG — Google can't index this, and visitors can't preview
if (!isLoggedIn) return <LoginPrompt />;
return <CourseContent />;
 
// RIGHT — show course info publicly, gate video playback
return (
  <>
    <CourseHero course={course} />        {/* Always visible */}
    <CourseSyllabus sections={sections} /> {/* Always visible */}
    <VideoPlayer requiresAuth />           {/* Gated */}
  </>
);

4. Forgetting ISR/Revalidation

// WRONG — fetches on every request (slow)
const res = await fetch(url);
 
// WRONG — caches forever (stale data)
const res = await fetch(url, { cache: "force-cache" });
 
// RIGHT — revalidates periodically
const res = await fetch(url, { next: { revalidate: 300 } }); // 5 minutes

What's Next?

The public catalog is live — courses are indexed, visitors can preview content, and the subscription funnel is complete. In Post #12, we'll build admin analytics:

  • Dashboard with key metrics (revenue, subscribers, completions)
  • Revenue charts with Recharts
  • Popular lessons and completion rates
  • User management with search and manual access grants
  • Export functionality for reports

Time to give the admin visibility into what's happening on the platform.

Series: Build a Video Streaming Platform
Previous: Phase 9: Stripe Subscription Integration
Next: Phase 11: Admin Analytics & User Management

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.