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 ·{" "}
{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 ·{" "}
{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-fundamentals4. Test Enrollment Flow
- Open
/courses/spring-boot-fundamentalsin incognito - Verify: course info visible, lessons show lock icons, free previews show "Watch" link
- Click "Subscribe to Watch" → should redirect to login
- Log in → should redirect to pricing
- 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 minutesWhat'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.