Build a Udemy Clone with Strapi and Next.js (Part 2)
— Strapi, Next.js — 21 min read
Introduction
Authentication
- Setting up authentication
- Configuring user roles & permissions
- Creating a register & login page, and then implementing authentication with Next.js
AuthCheck component
Let's create a component that we can reuse for multiple pages to check if there is an authenticated user.
frontend/components/AuthCheck.jsx
import React, { useEffect } from 'react';import { useRouter } from 'next/navigation';
const AuthCheck = () => { const router = useRouter();
useEffect(() => { const token = localStorage.getItem('token'); if (token) { router.push('/'); } }, [router]);
return null;};
export default AuthCheck;
With this component we will have the ability to check if there is authenticated user and if not we can push the user to the homepage.
Register page
Now we need to create a page so user can register and use our app.
npx shadcn@latest add card inputnpm i js-cookie
frontend/app/register/page.js
"use client";import React, { useState } from 'react';import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";import axios from 'axios';import AuthCheck from '@/components/AuthCheck';import { useRouter } from 'next/navigation';
const RegisterPage = () => { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const router = useRouter();
const handleRegister = async (e) => { e.preventDefault();
try { const response = await axios.post('http://localhost:1337/api/auth/local/register', { username: username, email: email, password: password, });
const { jwt, user } = response.data;
localStorage.setItem('token', jwt); localStorage.setItem('user', JSON.stringify(user));
setSuccess('Registration successful! You can now log in.'); setError(''); setUsername(''); setEmail(''); setPassword(''); router.push('/');
} catch (error) { console.error('Registration failed:', error); setError('Registration failed. Please try again.'); } };
return ( <div className="flex items-center justify-center h-screen"> <AuthCheck/> <Card className="w-full max-w-md p-8"> <CardHeader> <CardTitle>Register</CardTitle> <CardDescription>Create a new account by filling in the information below.</CardDescription> </CardHeader> <CardContent> <form onSubmit={handleRegister}> <div className="mb-4"> <Input type="text" placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} required /> </div> <div className="mb-4"> <Input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="mb-4"> <Input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> {error && <p className="text-red-500 text-sm mb-4">{error}</p>} {success && <p className="text-green-500 text-sm mb-4">{success}</p>} <Button type="submit" className="w-full bg-blue-500 text-white"> Register </Button> </form> </CardContent> </Card> </div> );};
export default RegisterPage;
In the code above we created a form that will sends a POST request to the Strapi register endpoint.
Upon successful registration, the user's JWT token and profile are saved to local storage, and then redirected to the home page.

Now after the user can register & logout, let's implement the login page.
frontend/app/login/page.js
"use client";import React, { useState } from 'react';import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";import { useRouter } from 'next/navigation';import axios from 'axios';
const LoginPage = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const router = useRouter();
const handleLogin = async (e) => { e.preventDefault();
try { const response = await axios.post('http://localhost:1337/api/auth/local', { identifier: email, password: password, });
const { jwt, user } = response.data; localStorage.setItem('token', jwt); localStorage.setItem('user', JSON.stringify(user));
router.push('/');
} catch (error) { console.error('Login failed:', error); setError('Invalid email or password.'); } };
return ( <div className="flex items-center justify-center h-screen"> <Card className="w-full max-w-md p-8"> <CardHeader> <CardTitle>Login</CardTitle> <CardDescription>Enter your credentials to access your account.</CardDescription> </CardHeader> <CardContent> <form onSubmit={handleLogin}> <div className="mb-4"> <Input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="mb-4"> <Input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> {error && <p className="text-red-500 text-sm mb-4">{error}</p>} <Button type="submit" className="w-full bg-blue-500 text-white"> Login </Button> </form> </CardContent> </Card> </div> );};
export default LoginPage;
In the code above we created a form that will sends a POST request to the Strapi login endpoint.
If the login is successful, the user's JWT token and profile are saved to local storage, and then redirected to the home page.

Now you should be able to see this navbar if you are authenticated

2. Manage courses page
Configuring CRUD permission for courses
For simplicity sake we will allow all authenticated user to do add, edit and delete courses. In a real project we should only allow lectures edit their own courses.
To setup the permission go to Users & Permissions plugin => Roles => Authenticated
or visit http://localhost:1337/admin/settings/users-permissions/roles/1
Then add permission for create, find, update & delete for course collection, lecture collection & section collection.

Building UI for CRUD actions
On top of managing the courses in the Strapi admin, users will be able to manage it in manage course page in our app.
frontend/app/manage-courses/page.jsx
"use client";import Navbar from "@/components/Navbar";import { useState, useEffect } from "react";import axios from "axios";import Link from "next/link";import { Button } from "@/components/ui/button";
const ManageCoursesPage = () => { const [courses, setCourses] = useState([]);
useEffect(() => { const fetchCourses = async () => { try { const response = await axios.get(`http://localhost:1337/api/courses`);
setCourses(response.data.data); } catch (error) { console.error(error); } };
fetchCourses(); }, []);
const handleDeleteCourse = async (documentId) => { const token = localStorage.getItem("token"); const config = { headers: { Authorization: `Bearer ${token}` }, }; try { await axios.delete( `http://localhost:1337/api/courses/${documentId}`, config, ); setCourses(courses.filter((course) => course.documentId !== documentId)); alert("Course deleted successfully"); } catch (error) { console.error(error); } };
return ( <div className="min-h-screen bg-gray-100 text-center p-24"> <Navbar /> <h1 className="text-3xl font-semibold mb-6">Manage courses</h1> <Button className="mb-6">Create new course</Button> <div className="flex flex-col items-start gap-6 w-1/2"> {courses.map((course) => ( <div className="py-12 pl-6 text-left w-full border text-card-foreground bg-white shadow-md rounded-md"> <h2 className="text-xl font-semibold mb-4">{course.title}</h2> <Link href={`/manage-courses/${course.slug}`} key={course.documentId} className="text-blue-500 mr-6" > <Button>Edit</Button> </Link> <Button onClick={() => handleDeleteCourse(course.documentId)}> Delete </Button> </div> ))} </div> </div> );};
export default ManageCoursesPage;
In the code above we fetch and display courses from Strapi endpoint. We also created handleDeleteCourse
function to send DELETE request and then update the state accordingly.

Now we have the ability to display & delete courses.
Next feature to implement are the create & edit course, and for that we will use a modal.
But before that let's replace the alert for deleting course with toast.
npx shadcn@latest add toast
Currently toast component from shadcn doesn't a success variant, so we will create one.
frontend/components/ui/toast.jsx
In the variant add a success variant
success: "success group border-green-500 bg-green-500 text-neutral-50"
In frontend/app/layout.js
add the following code
import { Toaster } from "@/components/ui/toaster"
// below the {children}<Toaster />
In frontend/app/manage-courses/page.jsx
Add the following code
import { useToast } from "@/hooks/use-toast";
const { toast } = useToast();
// replace the alert withtoast({ title: "Success", description: "Course deleted successfully", duration: 3000, variant: "success",});
You should be able to see the toast after deleting a course.

Now let's continue creating a modal for the course form.
frontend/components/Modal.jsx
import React from "react";
export default function Modal({ isOpen, onClose, children }) { if (!isOpen) return null;
return ( <div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"> <div className="bg-white rounded-lg p-6 w-1/3"> <button onClick={onClose} className="text-black float-right font-bold" > × </button> <div>{children}</div> </div> </div> );}
Now in frontend/app/manage-courses/page.jsx
add the following code
import Modal from '@/components/Modal';
const [isModalOpen, setIsModalOpen] = useState(false);
const handleOpenModal = () => setIsModalOpen(true); const handleCloseModal = () => setIsModalOpen(false);
<Button className="mb-6" onClick={handleOpenModal}>Create new course</Button>
<Modal isOpen={isModalOpen} onClose={handleCloseModal}> <h1>This is placeholder for modal content</h1> </Modal>
Now when button for "Create new course" clicked, you should be able to see

Now we are ready to create a component to hold the form for create & edit course.
frontend/components/CourseForm.jsx
import React, { useState, useEffect } from "react";import axios from "axios";import { useToast } from "@/hooks/use-toast";import { useRouter } from 'next/navigation';
export default function CourseForm({ onClose, setCourses, existingCourse }) { const router = useRouter(); const { toast } = useToast(); const [formData, setFormData] = useState({ title: "", description: "", slug: "", thumbnail: "", sections: [], });
useEffect(() => { if (existingCourse) { setFormData({ title: existingCourse.title, description: existingCourse.description, slug: existingCourse.slug, thumbnail: existingCourse.thumbnail, }); } }, [existingCourse]);
const handleChange = (e) => { setFormData({ ...formData, [e.target.name]: e.target.value }); };
const handleSubmit = async (e) => { e.preventDefault(); const token = localStorage.getItem("token"); const config = { headers: { Authorization: `Bearer ${token}` }, };
const payload = { title: formData.title, description: formData.description, slug: formData.slug, thumbnail: formData.thumbnail, }
try { if (existingCourse) { const res = await axios.put( `http://127.0.0.1:1337/api/courses/${existingCourse.documentId}`, { data: payload }, config, ); toast({ title: "Success", description: "Course updated successfully", duration: 3000, variant: "success", }); router.push(`/manage-courses`);
} else { const res = await axios.post( "http://127.0.0.1:1337/api/courses", { data: payload }, config, ); toast({ title: "Success", description: "Course created successfully", duration: 3000, variant: "success", }); setCourses((courses) => [...courses, res.data.data]); onClose(); } } catch (error) { console.error("Error creating/editing course:", error); } };
return ( <form onSubmit={handleSubmit}> <div className="mb-4"> <label className="block text-gray-700 text-sm font-bold mb-2"> Course Name </label> <input type="text" name="title" value={formData.title} onChange={handleChange} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700" required /> </div> <div className="mb-4"> <label className="block text-gray-700 text-sm font-bold mb-2"> Description </label> <textarea name="description" value={formData.description} onChange={handleChange} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700" required /> </div> <div className="mb-4"> <label className="block text-gray-700 text-sm font-bold mb-2"> Slug </label> <input type="text" name="slug" value={formData.slug} onChange={handleChange} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700" required /> </div> <div className="mb-4"> <label className="block text-gray-700 text-sm font-bold mb-2"> Thumbnail </label> <input type="text" name="thumbnail" value={formData.thumbnail} onChange={handleChange} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700" /> </div> <div className="flex justify-end"> {!existingCourse && ( <button type="button" onClick={onClose} className="bg-gray-400 text-white px-4 py-2 rounded mr-2" > Cancel </button> )} <button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded" > {existingCourse ? "Update Course" : "Create Course"} </button> </div> </form> );}
This component will allow users to create and update course details via a form. The form will either sends a POST request to create a new course or a PUT request to update an existing one.
Now in frontend/app/manage-courses/page.jsx
import CourseForm from "@/components/CourseForm";
// replace the modal content placeholder with below<CourseForm onClose={handleCloseModal} setCourses={setCourses}
Now user will be able to create a new course.

For editing the course, we will create a separate page
frontend/app/manage-courses/[slug]/page.jsx
"use client";import CourseForm from '@/components/CourseForm';import Navbar from "@/components/Navbar";import axios from "axios";
async function getCourseBySlug(slug) { try { const response = await axios.get("http://127.0.0.1:1337/api/courses", { params: { filters: { slug: { $eq: slug, }, }, populate: { sections: { populate: "lectures", }, }, }, });
const course = response.data.data[0]; return course || null; } catch (error) { console.error("Error fetching course:", error); return null; }}
export default async function EditCoursePage({ params}) { const course = await getCourseBySlug(params.slug);
if (!course) { return ( <div> <h1>We can’t find the page you’re looking for</h1> <a href="/">Go back</a> </div> ); }
return ( <main className="flex flex-col p-24"> <Navbar /> <CourseForm existingCourse={course}/> </main> );}
In the edit course page, we will fetch course details based on slug and then populates the form. When user click on "Update Course" button we will make a PUT request to modify the course.

Now let's deal with the more complicated part which is the section & lectures.
Managing Sections
We will use accordion to make it collapsible and cleaner UI.
npx shadcn@latest add accordion
Modify the useEffect to populate sections & lectures in frontend/components/CourseForm.jsx
useEffect(() => { if (existingCourse) { setFormData({ // existing code sections: existingCourse.sections .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) .map((section) => ({ ...section, lectures: section.lectures ? section.lectures .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)) .map((lecture) => ({ ...lecture, documentId: lecture.documentId, })) : [], })), }); } }, [existingCourse]);
After that let's put all utils related to section in here
frontend/lib/sectionUtils.js
import axios from "axios";
export const handleAddNewSection = (setFormData) => { setFormData((prevFormData) => ({ ...prevFormData, sections: [...prevFormData.sections, { title: "" }], }));};
export const handleDeleteSection = async (formData, setFormData, index,toast) => { const token = localStorage.getItem("token"); const config = { headers: { Authorization: `Bearer ${token}` }, }; const sectionId = formData.sections[index].documentId; await axios.delete( `http://127.0.0.1:1337/api/sections/${sectionId}`, config, ); setFormData((prevFormData) => ({ ...prevFormData, sections: prevFormData.sections.filter((section, i) => i !== index), })); toast({ title: "Success", description: "Section deleted successfully!", duration: 3000, variant: "success", });};
export const handleChangeSection = (setFormData,index, value) => { setFormData((prevFormData) => ({ ...prevFormData, sections: prevFormData.sections.map((section, i) => i === index ? { ...section, title: value } : section, ), }));};
export const handleSaveSection = async (formData,setFormData,index,existingCourseId,toast) => { const token = localStorage.getItem("token"); const config = { headers: { Authorization: `Bearer ${token}` }, };
const section = formData.sections[index]; const sectionPayload = { title: section.title, course: { connect: [existingCourseId], }, };
try { let res; if (section.documentId) { res = await axios.put( `http://127.0.0.1:1337/api/sections/${section.documentId}`, { data: sectionPayload }, config, ); } else { res = await axios.post( "http://127.0.0.1:1337/api/sections", { data: sectionPayload }, config, ); }
setFormData((prevFormData) => ({ ...prevFormData, sections: prevFormData.sections.map((s, i) => i === index ? { ...s, documentId: res.data.data.id } : s, ), }));
toast({ title: "Success", description: section.documentId ? "Section updated successfully!" : "Section saved successfully!", duration: 3000, variant: "success", }); } catch (error) { console.error("Error saving/updating section:", error); toast({ title: "Error", description: "Error saving/updating section. Please try again.", variant: "destructive", duration: 3000,1. `handleAddNewSection` }); }};
-
handleAddNewSection
will add a new empty section to UI by updating the section state array. -
handleDeleteSection
will send a DELETE request and then update the state accordingly. -
handleChangeSection
will update section title based on it's index in section state array. -
handleSaveSection
will send either POST or PUT request and then update the state with the response.
Now in frontend/components/CourseForm.jsx
wrap the form element in a div, and then below the form add the following code.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger,} from "@/components/ui/accordion";
// put this below the form<Accordion type="single" collapsible className="w-full">{formData.sections.map((section, sectionIndex) => ( <AccordionItem key={sectionIndex} value={`section-${sectionIndex}`}> <AccordionTrigger className="text-left"> {section.title || `Section ${sectionIndex + 1}`} </AccordionTrigger> <AccordionContent> <div className="mb-4"> <input value={section.title} onChange={(e) => handleChangeSection(sectionIndex, e.target.value) } className="shadow appearance-none border rounded w-full py-2 my-2 px-3 text-gray-700" placeholder="Section title" /> <div className="flex justify-end space-x-2 mt-2"> <button type="button" onClick={() => handleSaveSection(sectionIndex)} className="bg-blue-500 text-white px-4 py-2 rounded" > Save Section </button> <button type="button" onClick={() => handleDeleteSection(sectionIndex)} className="bg-red-500 text-white px-4 py-2 rounded" > Delete Section </button> </div> </div> </AccordionContent> </AccordionItem>))}</Accordion>
<button type="button" onClick={handleAddNewSection} className="bg-blue-500 text-white px-4 py-2 rounded mt-4" > Add new section </button>
Now we can perform CRUD action for the section.

Managing Lectures
Same as section, we will put the utils related to lecturesi in separate file frontend/lib/lectureUtils.js
import axios from "axios";
export const handleAddLecture = (setFormData,sectionIndex) => { setFormData((prevFormData) => ({ ...prevFormData, sections: prevFormData.sections.map((section, index) => index === sectionIndex ? { ...section, lectures: [ ...(section.lectures || []), { title: "", videoURL: "", duration: 0 }, ], } : section, ), }));};
export const handleChangeLecture = (setFormData,sectionIndex, lectureIndex, field, value) => { setFormData((prevFormData) => ({ ...prevFormData, sections: prevFormData.sections.map((section, index) => index === sectionIndex ? { ...section, lectures: section.lectures.map((lecture, idx) => idx === lectureIndex ? { ...lecture, [field]: value } : lecture, ), } : section, ), }));};
export const handleDeleteLecture = async (setFormData,formData,toast,sectionIndex, lectureIndex) => { const token = localStorage.getItem("token"); const config = { headers: { Authorization: `Bearer ${token}` }, };
const lecture = formData.sections[sectionIndex].lectures[lectureIndex];
try { if (lecture.id) { await axios.delete( `http://127.0.0.1:1337/api/lectures/${lecture.documentId}`, config, ); }
setFormData((prevFormData) => ({ ...prevFormData, sections: prevFormData.sections.map((section, index) => index === sectionIndex ? { ...section, lectures: section.lectures.filter( (_, idx) => idx !== lectureIndex, ), } : section, ), })); toast({ title: "Success", description: "Lecture deleted successfully!", duration: 3000, }); } catch (error) { console.error("Error deleting lecture:", error); toast({ title: "Error", description: "Error deleting lecture. Please try again.", variant: "destructive", duration: 3000, }); }};
export const handleSaveLecture = async (setFormData,formData,toast,sectionIndex, lectureIndex) => { const token = localStorage.getItem("token"); const config = { headers: { Authorization: `Bearer ${token}` }, };
const lecture = formData.sections[sectionIndex].lectures[lectureIndex]; const lecturePayload = { title: lecture.title, videoURL: lecture.videoURL, duration: lecture.duration, section: { connect: [formData.sections[sectionIndex].documentId], }, };
try { let res; if (lecture.documentId) { res = await axios.put( `http://127.0.0.1:1337/api/lectures/${lecture.documentId}`, { data: lecturePayload }, config ); } else { res = await axios.post( "http://127.0.0.1:1337/api/lectures", { data: lecturePayload }, config ); }
setFormData((prevFormData) => ({ ...prevFormData, sections: prevFormData.sections.map((section, i) => i === sectionIndex ? { ...section, lectures: section.lectures.map((lec, j) => j === lectureIndex ? { ...lec, documentId: res.data.data.id } : lec ), } : section ), }));
toast({ title: "Success", description: lecture.documentId ? "Lecture updated successfully!" : "Lecture saved successfully!", duration: 3000, variant: "success", }); } catch (error) { console.error("Error saving/updating lecture:", error); toast({ title: "Error", description: "Error saving/updating lecture. Please try again.", variant: "destructive", duration: 3000, }); }};
And then in frontend/components/CourseForm.jsx
// below the delete section button add<Accordion type="single" collapsible className="mt-4 ml-6"> {section.lectures && section.lectures.map((lecture, lectureIndex) => ( <AccordionItem key={lectureIndex} value={`lecture-${sectionIndex}-${lectureIndex}`} > <AccordionTrigger className="text-left"> {lecture.title || `Lecture ${lectureIndex + 1}`} </AccordionTrigger> <AccordionContent> <input value={lecture.title} onChange={(e) => handleChangeLecture( setFormData, sectionIndex, lectureIndex, "title", e.target.value, ) } className="shadow appearance-none border rounded w-full py-2 my-2 px-3 text-gray-700" placeholder="Lecture Title" /> <input value={lecture.videoURL} onChange={(e) => handleChangeLecture( setFormData, sectionIndex, lectureIndex, "videoURL", e.target.value, ) } className="shadow appearance-none border rounded w-full py-2 my-2 px-3 text-gray-700" placeholder="Video URL" /> <input type="number" value={lecture.duration} onChange={(e) => handleChangeLecture( setFormData, sectionIndex, lectureIndex, "duration", parseInt(e.target.value, 10), ) } className="shadow appearance-none border rounded w-full py-2 my-2 px-3 text-gray-700" placeholder="Duration (in seconds)" /> <div className="flex justify-end space-x-2 mt-2"> <button type="button" onClick={() => handleSaveLecture( setFormData, formData, toast, sectionIndex, lectureIndex, ) } className="bg-green-500 text-white px-4 py-2 rounded" > Save Lecture </button> <button type="button" onClick={() => handleDeleteLecture( setFormData, formData, toast, sectionIndex, lectureIndex, formData, ) } className="bg-red-500 text-white px-4 py-2 rounded" > Delete Lecture </button> </div> </AccordionContent> </AccordionItem> ))}</Accordion>
<button type="button" onClick={() => handleAddLecture(setFormData, sectionIndex)} className="bg-green-500 text-white px-4 py-2 rounded mt-4"> Add Lecture</button>
Now user can manage lectures easily.

3. Course Progress
Go to the Content-Type Builder, and then click on "Crete new collection type".

Let's create CourseProgress collection to save user progress with the following fields:
user: Relation ( User has many CourseProgresses) course: Relation (CourseProgress has one Course) completedLectures: JSON progressPercentage: Number (Integer)

And then enable the create, find and update for the Authenticated Users.

Also, we need to enable find for User permission, we need this to filter course progress that belong to a user.

In frontend
npx shadcn@latest add checkbox
Create `frontend/app/my-learning/page.jsx
"use client";import React, { useState, useEffect } from "react";import axios from "axios";import Navbar from "@/components/Navbar";import Link from "next/link";
const MyLearningPage = () => { const [courseProgresses, setCourseProgresses] = useState([]); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { const fetchCourseProgresses = async () => { try { const token = localStorage.getItem("token");
const currentUser = JSON.parse(localStorage.getItem("user")); const config = { headers: { Authorization: `Bearer ${token}` }, }; const response = await axios.get(`http://localhost:1337/api/course-progresses?filters[user][id][$eq]=${currentUser.id}&populate=course`,config); setCourseProgresses(response.data.data); setIsLoading(false); } catch (error) { console.error("Error fetching course progresses:", error); setIsLoading(false); } };
fetchCourseProgresses(); }, []);
if (isLoading) { return ( <div className="flex flex-col justify-center items-center h-screen"> <div className="animate-spin rounded-full h-32 w-32 border-t-4 border-b-4 border-blue-500"></div> <div className="text-2xl font-bold mt-6">Loading...</div> </div> ); }
return ( <main className="flex flex-col px-24 pt-12 bg-white w-full"> <Navbar /> <h1 className="text-3xl font-bold mb-6">My Learning</h1> {courseProgresses.length === 0 ? ( <p>You haven't started any courses yet.</p> ) : ( <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> {courseProgresses.map((progress) => ( <div key={progress.id} className="border rounded-lg p-4 shadow-md"> <h2 className="text-xl font-semibold">{progress.course.title}</h2> <p>Progress: {progress.progressPercentage}%</p> <Link href={`/my-learning/${progress.course.slug}`} className="mt-4 inline-block bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" > Continue Learning </Link> </div> ))} </div> )} </main> );};
export default MyLearningPage;
In MyLearning page above we fetches user's course progress and display that information along with "Continue Learning" button to navigate to the course watch page.

Let's create the course watch page, but first we need a loader component to display while we are fetching the data.
frontend/components/Loader.jsx
import React from 'react'
const Loader = () => { return ( <div className="flex flex-col justify-center items-center h-screen"> <div className="animate-spin rounded-full h-32 w-32 border-t-4 border-b-4 border-blue-500"></div> <div className="text-2xl font-bold mt-6">Loading...</div> </div> );}
export default Loader;
Now for the page
frontend/app/my-learning/[courseSlug]/page.jsx
"use client";import Navbar from "@/components/Navbar";import axios from "axios";import { useState, useEffect, useCallback } from "react";import { Checkbox } from "@/components/ui/checkbox";import Loader from "@/components/Loader";
export default function WatchCoursePage({ params }) { const [course, setCourse] = useState(null); const [userProgress, setUserProgress] = useState([]); const [completedLectures, setCompletedLectures] = useState([]); const [courseProgressId, setCourseProgressId] = useState(null);
const [selectedVideoId, setSelectedVideoId] = useState(); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { setSelectedVideoId(course?.sections[0]?.lectures[0]?.videoURL); }, [course]);
useEffect(() => { const fetchCourseAndProgress = async () => { try { const courseResponse = await axios.get( "http://127.0.0.1:1337/api/courses", { params: { filters: { slug: { $eq: params.courseSlug, }, }, populate: { sections: { populate: "lectures", }, }, }, }, ); const courseData = courseResponse.data.data[0]; setCourse(courseData); setIsLoading(false); const token = localStorage.getItem("token"); const currentUser = localStorage.getItem("user"); const currentUserId = JSON.parse(currentUser).id; const config = { headers: { Authorization: `Bearer ${token}` }, }; const userProgressResponse = await axios.get( `http://localhost:1337/api/course-progresses?filters[user][id][$eq]=${currentUserId}`, { ...config, }, );
const userProgressData = userProgressResponse.data.data; setUserProgress(userProgressData); setCourseProgressId(userProgressData[0]?.documentId);
const completedLecturesData = userProgressData?.[0]?.completedLectures || []; setCompletedLectures(completedLecturesData); } catch (error) { console.error("Error fetching data:", error); } };
fetchCourseAndProgress(); }, [params.courseSlug]);
const handleCheckboxChange = useCallback( async (lectureId, isChecked) => { try { const token = localStorage.getItem("token"); const config = { headers: { Authorization: `Bearer ${token}` }, };
let updatedCompletedLectures; if (isChecked) { updatedCompletedLectures = [...completedLectures, lectureId]; } else { updatedCompletedLectures = completedLectures.filter( (id) => id !== lectureId, ); }
const totalLectures = course?.sections.reduce( (acc, section) => acc + section.lectures.length, 0, ) || 0; const completedCount = updatedCompletedLectures.length;
await axios.put( `http://localhost:1337/api/course-progresses/${courseProgressId}`, { data: { completedLectures: updatedCompletedLectures, progressPercentage: Math.round( (completedCount / totalLectures) * 100, ), }, }, config, );
setCompletedLectures(updatedCompletedLectures); } catch (error) { console.error("Error updating course progress:", error); } }, [completedLectures, courseProgressId], );
if (!isLoading && !course) { return ( <div> <h1>We can’t find the page you’re looking for</h1> <a href="/">Go back</a> </div> ); }
const handleSelectLecture = (videoId) => { setSelectedVideoId(videoId); };
if (isLoading) return <Loader />;
return ( <main className="flex flex-col pt-4 px-24 h-screen"> <Navbar /> <div className="flex justify-between w-full gap-12 pt-12 h-full"> <div className="flex flex-col h-full justify-start w-full"> <div className="w-full max-h-full h-3/4 "> <iframe className="block" width="100%" height="100%" src={selectedVideoId} title="Ultimate Guide To Web Scraping - Node.js & Python (Puppeteer & Beautiful Soup)" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen ></iframe> </div> <div> <h1 className="text-3xl mb-4 font-semibold">{course.name}</h1> <p className="">{course.description}</p> </div> </div> <div className="w-1/4"> {course.sections.length > 0 && course.sections.map((section) => { return ( <div key={section.id} className=""> <h2 className="text-xl font-semibold">{section.title}</h2> <ul className="list-disc"> {section.lectures.length > 0 && section.lectures.map((lecture) => { const isCompleted = completedLectures.includes( lecture.documentId, ); return ( <li className="flex items-center gap-6 mb-4 hover:bg-gray-400 p-2 rounded-md cursor-pointer" key={lecture.id} onClick={() => handleSelectLecture(lecture.videoURL) } > {lecture.title} <Checkbox checked={isCompleted} className="h-6 w-6" onCheckedChange={(checked) => handleCheckboxChange( lecture.documentId, checked, ) } onClick={(e) => e.stopPropagation()} /> </li> ); })} </ul> </div> ); })} </div> </div> </main> );}
In the Watch Course page we display the video content along with checkbox that allow user to mark their progress.

Implementing Course search
Now let's implement a search so user can easily find a course that interest them.
First, we need a util function that will prevent the search to run too many times when user type
frontend/hooks/useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delay){ const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay);
return () => { clearTimeout(handler); }; }, [value, delay]);
return debouncedValue;}
And then create frontend/components/SearchBar.jsx
import { Search } from "lucide-react";import Link from "next/link";import { Input } from "@/components/ui/input";
const SearchBar = ({ searchTerm, setSearchTerm, searchResults }) => ( <div className="relative w-full max-w-md"> <div className="relative"> <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} /> <Input type="text" placeholder="Search courses..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10 pr-4 py-2 w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" /> </div> {searchResults && searchResults.length > 0 && ( <div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg"> {searchResults.map((course) => ( <Link key={course.id} href={`/course/${course.slug}`} className="block px-4 py-2 hover:bg-gray-100" > {course.title} </Link> ))} </div> )} </div>);
export default SearchBar;
Now modify Navbar.jsx to manage the state and render SearchBar.jsx
//... existing codeconst [searchTerm, setSearchTerm] = useState("");const [searchResults, setSearchResults] = useState(null);const debouncedSearchTerm = useDebounce(searchTerm, 300);
useEffect(() => { const storedUserData = localStorage.getItem("user"); if (storedUserData) setUserData(JSON.parse(storedUserData)); }, []);
useEffect(() => { if (!debouncedSearchTerm) { setSearchResults(null); return; }
fetch(`http://localhost:1337/api/courses?filters[title][$contains]=${debouncedSearchTerm}`) .then(response => response.json()) .then(data => setSearchResults(data.data || [])) .catch(error => { console.error("Error fetching search results:", error); setSearchResults([]); }); }, [debouncedSearchTerm]);
Here's the result

Conclusion
In this part we learned how to:
- implement user authentication
- build manage courses page
- track user course progress
- Course search
And how to do it both in Strapi side & Next.js side.
You can find the full code in https://github.com/ZehaIrawan/strapi-udemy-clone
In part 1, we configured Strapi & Next.js for public all courses & course overview page.
In the next part, we will learn how to implement rating system, cart feature & payment with Stripe.