Skip to content

Build a Udemy Clone with Strapi and Next.js (Part 2)

Strapi, Next.js21 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 input
npm 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.

register 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.

login page

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

authenticated navbar

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.

Authenticated roles permission

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.

display & delete course

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 with
toast({
title: "Success",
description: "Course deleted successfully",
duration: 3000,
variant: "success",
});

You should be able to see the toast after deleting a course.

toast

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"
>
&times;
</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

modal placeholder

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.

create courses

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.

edit courses

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`
});
}
};
  1. handleAddNewSection will add a new empty section to UI by updating the section state array.

  2. handleDeleteSection will send a DELETE request and then update the state accordingly.

  3. handleChangeSection will update section title based on it's index in section state array.

  4. 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.

handle 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.

handle lectures

3. Course Progress

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

course progress collection

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)

user course progress

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

course progress

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

user permission

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.

my learning

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 &amp; Python (Puppeteer &amp; 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.

watch course

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 code
const [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

search

Conclusion

In this part we learned how to:

  1. implement user authentication
  2. build manage courses page
  3. track user course progress
  4. 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.