Skip to content

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

Strapi, Next.js33 min read

Introduction

This article is part one of a blog series where we will build a Udemy clone with Strapi backend. At the end of the series our clone will have the following features:

  • Manage course page where user can perform CRUD actions
  • User authentication
  • User progress tracking
  • Rating system
  • Cart & payment with Stripe

Demo:

Demo - 480

Tutorial Outline

We will divide the series as follows:

  • Part 1: Setting up Strapi & Next.js for public all courses & course overview page
  • Part 2: Implementing user authentication, manage courses page & user progress
  • Part 3: Implementing rating system, cart & payment with Stripe

We will use as little customization as possible, such as custom middleware and controller to achieve functionality that we want.

Prerequisites

Creating a New Strapi 5 Project

Run the following command to install Strapi

mkdir udemy-clone && cd udemy-clone
npx create-strapi-app@rc backend --quickstart

After successful installation you will redirected to http://localhost:1337/admin/auth/register-admin

Strapi registration

Fill the required information and you should get redirected to homepage

strapi home

Strapi is ready to use!

Create and Configure Strapi Collection Types

1. Create Course Collection Type

Go to Content-Type Builder and then click on Create new collection type.

Create a new collection type called Course

Create Strapi new collection called course

This collection has the following fields:


Field NameData Type
titleText
descriptionText
slugText
thumbnailText
categoryEnumeration
priceNumber (Integer)
isPurchasedBoolean

Set default value of isPurchased to false in the advanced settings.

For the category field let's use the following as values, you can modify this to whatever values you like.

  • Python
  • Ruby
  • Javascript
course enumeration

Then click 'Save'.

2. Create Section Collection Type

The Section collection type will contain the following fields:


Field NameData Type
titleText
courseRelation

For the course relation, select from the dropdown and choose Course has many Sections.

Then click 'Save'.

Pro tip

When you create a relation, create it in the child collection and not the parent. This will allow you to define the relations when creating an entry or document.

In this case, Section is the parent and Course is the child.

{
"data": {
"name": SECTION_NAME,
"course": {
"connect": [DOCUMENT_ID_OF_THE_COURSE]
}
}
}

You can read more about it here.

For example here we have Course & Section Collection Types.

Course has many section

When you set the above relation, It will automatically create this relation in the Course collection type.

auto created relation

I made the mistake of putting it in parent the first time I tried it and it requires multiple API calls for connect, not to mention resulted in several bugs.

You can read more about understanding & using relations in Strapi.

3. Create Lecture Collection Type

Create the Lecture collection type with the following fields:


Field NameData Type
titleText
videoURLText
durationNumber (Integer)
sectionRelations

For the section relation, select from the dropdown and choose Section has many Lectures.

Then click 'Save'.

4. Create CourseProgress Collection Type

Create a new collection type called CourseProgress as shown in the image below.

course progress collection

CourseProgress collection will save user progress with the following fields:


Field NameData Type
userRelation (User has many CourseProgresses)
courseRelation (CourseProgress has one Course)
completedLecturesJSON
progressPercantageNumber (Integer)

user course progress

Configuring API Permissions in Strapi

Go to Users & Permission plugin > Roles > Public and enable find for Lecture, Section, Course, and CourseProgress collection types.

Authenticated roles permission

If permission is configured properly you will run into this problem when making a request to the Strapi API.

{
"data": null,
"error": {
"status": 403,
"name": "ForbiddenError",
"message": "Forbidden",
"details": {}
}
}

The error above is a forbidden error that is retured when you are do not have permission to access an API endpoint.

Configure User Permissions

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

Configure User Permissions

Create Entries and Test API endpoints in Strapi

In Strapi admin dashboard, navigate to content manager and perform the following operations.

1. Create Course entry

Create a course entry with the following information:

  • title: Python Fundamentals
  • description: Designed for beginners who want to learn the basics of Python programming.
  • slug: python-fundamentals
  • thumbnail: https://www.mooc.org/hubfs/python-applications.jpg
  • category: Python
  • price: 12
  • isPurchased: false

Leave the section relation empty and click publish.

2. Create section entry

Create a section entry with the following information:

  • title: Variables and Data Types
  • course: select "Python Fundamentals" from dropdown

Leave the lecture relation empty.

3. Create lecture entry

Finally, create a lecture entry with the following information:

Create lecture entry

Then publish, you can add more entry but the concept is the same.

Testing the API endpoints

curl -X GET "http://localhost:1337/api/courses"

You should be able to get the following response:

[ {
"id": 2,
"documentId": "lqhace5xsr85ljkaq42zlwps",
"title": "Python Fundamentals",
"description": "Designed for beginners who want to learn the basics of Python programming. ",
"slug": "python-fundamentals",
"thumbnail": "https://www.mooc.org/hubfs/python-applications.jpg",
"createdAt": "2024-09-14T19:48:28.552Z",
"updatedAt": "2024-09-14T19:48:28.552Z",
"publishedAt": "2024-09-14T19:48:28.730Z",
"locale": null,
"category": "Python"
}]

Setting up Next.js

Inside the udemy-clone folder, run the command below to create the latest version of Next.js. The command below will also install axios and start up our Next.js application.

npx create-next-app@latest frontend
npm i axios
cd frontend && npm run dev

We will use Tailwind CSS for the styling, you can opt out if you want to use your own styling.

Create All Courses Page: Building The UI and Integrating with Strapi

Inside the frontend/app/globals.css, change the preferred color scheme from dark to light.

@media (prefers-color-scheme: light) {

Let's modify frontend/app/page.js.

"use client";
import React, { useState, useEffect } from "react";
import axios from "axios";
import Link from "next/link";
const AllCoursesPage = () => {
const [courses, setCourses] = useState([]);
const [categories, setCategories] = useState({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchCourses = async () => {
try {
const response = await axios.get("http://localhost:1337/api/courses");
const fetchedCourses = response.data.data;
const groupedCourses = fetchedCourses.reduce((acc, course) => {
const category = course.category || "Uncategorized";
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(course);
return acc;
}, {});
setCourses(fetchedCourses);
setCategories(groupedCourses);
setIsLoading(false);
} catch (error) {
console.error("Error fetching courses:", error);
}
};
fetchCourses();
}, []);
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 (
<div className="flex flex-col p-24">
<div>
<Link href="/">Home</Link>
</div>
{!isLoading && courses.length === 0 ? (
<div>There are no courses available</div>
) : (
<div>
{Object.keys(categories).map((category) => (
<div key={category} className="mt-12">
<h2 className="text-2xl font-semibold text-gray-800">{category}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mt-6">
{categories[category].map((course) => (
<a
href={`/course/${course.slug}`}
key={course.id}
className="group block"
>
<div className="border border-gray-200 rounded-lg overflow-hidden cow-lg hover:shadow-xl transition-shadow duration-300">
<div className="relative w-full h-auto">
<img
src={course.thumbnail}
alt={course.title}
className="group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="p-4">
<p className="text-lg font-semibold group-hover:text-blue-600">
{course.title}
</p>
<p className="text-sm text-gray-600 mt-2">
{course.description}
</p>
</div>
</div>
</a>
))}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default AllCoursesPage;

In the code above:

  • We fetch the course data with axios using the GET HTTP method to Strapi API.
  • Next, we save the response in the states categories and courses.
  • Then we iterate through the category state and display the course.

You should be able to see something like this

All courses page

Create The Course Overview page

To create the Overview page, we have to create an accordion component in frontend/components/Accordion.jsx

"use client";
import { useState } from 'react';
const Accordion = ({ course,secondsToHMS }) => {
const [openSectionIndex, setOpenSectionIndex] = useState(0);
const toggleSection = (index) => {
setOpenSectionIndex(openSectionIndex === index ? null : index);
};
return (
<div className='border border-gray-300 mt-2'>
{course.sections.length > 0 &&
course.sections.map((section, index) => {
const isOpen = openSectionIndex === index;
return (
<div key={section.id}>
<div
className="flex justify-between items-center cursor-pointer bg-gray-100 p-4 border-b border-gray-300"
onClick={() => toggleSection(index)}
>
<h3 className="text-xl font-semibold text-gray-800">
{section.title}
</h3>
<span className={`text-sm text-gray-600 transition-transform duration-300 ${isOpen ? 'rotate-180' : 'rotate-0'}`}>
</span>
</div>
{isOpen && (
<ul className="list-disc text-gray-700">
{section.lectures.length > 0 &&
section.lectures.map((lecture) => (
<li key={lecture.id} className="flex justify-between mt-1 py-6 px-12">
<span> {lecture.title}</span>
<span className="text-sm text-gray-500">
{secondsToHMS(lecture.duration)}
</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
);
};
export default Accordion;

In the code above we will render the list of course sections and their lectures with collapsible accordion. Users will be able to toggle sections as opened or closed.

Let's create the course overview page inside the frontend/app/course/[slug]/page.js file

"use client";
import axios from "axios";
import Accordion from "@/components/Accordion";
import Link from "next/link";
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 Page({ 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>
);
}
const getTotalLectures = () => {
return course.sections.reduce(
(total, section) => total + section.lectures.length,
0,
);
};
const getTotalLecturesDuration = () => {
const totalSeconds = course.sections.reduce((total, section) => {
console.log(section.lectures, "section");
const sectionDuration = section.lectures[0]?.duration;
return total + sectionDuration;
}, 0);
return secondsToHMS(totalSeconds);
};
function secondsToHMS(duration) {
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = duration % 60;
const hoursStr = hours > 0 ? `${hours}:` : "";
const minutesStr =
minutes > 0 || hours > 0 ? String(minutes).padStart(2, "0") + ":" : "";
const secondsStr = String(seconds).padStart(2, "0");
return `${hoursStr}${minutesStr}${secondsStr}`;
}
return (
<main className="flex flex-col p-24">
<Link href="/" className="text-blue-500 hover:underline">
Home
</Link>
<div className="max-w-5xl w-full p-8">
<div>
<h1 className="text-4xl mb-6 font-bold text-gray-900">
{course.title}
</h1>
<p className="text-lg text-gray-700 mb-8">{course.description}</p>
<h2 className="text-2xl font-bold mt-8 text-gray-800 pb-2">
Course Content
</h2>
<ul className="flex gap-6 items-center text-sm text-gray-800 mt-4">
<li className="list-none">
{`${course.sections.length} sections`}
</li>
<li className="list-disc">{`${getTotalLectures()} lectures`}</li>
<li className="list-disc">
{`${getTotalLecturesDuration()} total length`}
</li>
</ul>
<Accordion course={course} secondsToHMS={secondsToHMS} />
</div>
</div>
</main>
);
}

In the code above:

  • We fetch a course by it's slug and populate the sections and lectures response data.

You can read more about Strapi populate in https://docs-next.strapi.io/dev-docs/api/rest/populate-select

  • The getTotalLecturesDuration and secondsToHMS are utility functions to convert duration to a readable format.

After storing the result in a useState hook, it will render the course content.

This the final result for part 1

Final part 1

Implement The Navbar Using Shadcn/UI

We will use Shadcn/UI to implement the Navbar. This will save us development time.

Install Shadcn/UI

In the frontend directory run the command below to install Shadcn/UI, add the avatar and button components and install lucide-react which is an implementation of the lucide icon library for React applications.

.

npx shadcn@latest init
npx shadcn@latest add avatar button
npm i lucide-react

You will be prompted to create components.json. I'm using New York style, Neutral base color and without CSS variables but you can choose based on your preference.

Create a the file frontend/app/components/NavAvatar.jsx and add the following code:

"use client";
import React, { useState } from "react";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
const NavAvatar = ({ userData }) => {
const router = useRouter();
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => setIsHovered(true);
const handleMouseLeave = () => setIsHovered(false);
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/login");
};
const initials = userData.email.slice(0, 2).toUpperCase();
return (
<div
className="relative flex items-center space-x-4"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Avatar className="h-12 w-12">
<AvatarImage src="" alt={`${initials} Avatar`} />
<AvatarFallback className="bg-blue-500 text-white">
{initials}
</AvatarFallback>
</Avatar>
{isHovered && (
<div
className="absolute top-12 right-0 bg-white border border-gray-200 rounded-md shadow-lg p-4 w-64 z-10"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex flex-col items-center">
<Button onClick={handleLogout}>Logout</Button>
<span className="text-gray-500 block">{userData.username}</span>
<span className="text-gray-500 block"> {userData.email}</span>
</div>
</div>
)}
</div>
);
};
export default NavAvatar;

In the code above we display user avatar's with their initial and use mouse events to provides hoverable dropdown menu.

After we are done setting up the Authentication part of this application, the NavAvatar will look like this

Avatar

Create Navbar Component

Now let's create the Navbar component file frontend/app/components/Navbar.jsx and add the following code:

"use client";
import { useState, useEffect } from "react";
import NavAvatar from "@/app/components/NavAvatar";
import { ShoppingCart } from "lucide-react";
import Link from "next/link";
const Navbar = () => {
const [userData, setUserData] = useState(null);
useEffect(() => {
const userData = localStorage.getItem("user");
if (userData) {
setUserData(JSON.parse(userData));
}
}, []);
return (
<div className="flex items-center justify-between gap-12">
<div className="flex gap-4">
<Link href="/">Home</Link>
</div>
<Link
href="/cart"
className="flex items-center space-x-2 p-2 rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors duration-200"
aria-label="View Cart"
>
<div className="relative flex items-center">
<ShoppingCart size={24} className="text-gray-700" />
</div>
</Link>
{userData && <Link href="/my-learning">My learning</Link>}
{userData && <Link href="/manage-courses">Manage Courses</Link>}
{userData ? (
<NavAvatar userData={userData} />
) : (
<div className="flex gap-4">
<Link href="/login">Login</Link>
<Link href="/register">Register</Link>
</div>
)}
</div>
);
};
export default Navbar;

In the code above we checked the local storage if there is a user signed in. If user exist, we will render the authenticated version of the Navbar. If not, we will render the register and login link.

Now, we can replace the Home link with the ** Navbar** component in all courses page frontend/app/page.js and in the course overview page frontend/app/course/[slug]/page.js

Implement Navbar

Now, let's learn how to implement user authentication, manage courses page & track user course progress.


Implementing Authentication

In this section, we will take a look at the following:

  • Setting up authentication
  • Configuring user roles & permissions in Strapi
  • Creating a register & login page, and then implementing authentication with Next.js

Create The 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 redirect the user to the home page.

Create The Registration Page

Now we need to creat the registration page that will allow users register and use our app.

Let's start by adding a card and an input via Shadcn/UI.

npx shadcn@latest add card input
npm i js-cookie

Modify the code inside the frontend/app/register/page.js file with the code below:

"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 sends a POST request to the Strapi registeration endpoint /api/auth/local/register.

Upon successful registration, the user's JWT token and profile are saved to local storage, and then redirected to the Home page.

register

Create Login Page

Modify the code inside the frontend/app/login/page.js file with the following to create a Login page:

"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 sends a POST request to the Strapi login endpoint /api/auth/local.

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

Now, a user should be able to see the Navbar if they are authenticated.

authenticated navbar

Create The Manage Courses page

Building UI for CRUD actions

Create the file frontend/app/manage-courses/page.jsx that will allow us manage courses.

"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

Create Toast for Deleting and Editing a Course

Now we have the ability to display & delete courses.

The next feature to implement is for users to be able to 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, the toast component from Shadcn/UI doesn't have a success variant, so we will create one. Update the variant in the frontend/components/ui/toast.jsx file with the following.

---
success: "success group border-green-500 bg-green-500 text-neutral-50"
---

Inside the frontend/app/layout.js file, import the Toaster component:

import { Toaster } from "@/components/ui/toaster"
// below the {children}
<Toaster />

In frontend/app/manage-courses/page.jsx file, add the following code to configure the toast.

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

Create Modal for Editing and Deleting a Course

Now let's create a modal for editing and deleting a course. Create the file frontend/components/Modal.jsx and add the following code:

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 the frontend/app/manage-courses/page.jsx file, import the modal above and 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>

With the code above, when the "Create new course" button is clicked, you should be able to see the modal below:

modal placeholder

Create Form for Create and Edit Course

Now we are ready to create a component to hold the form for create & edit course.

Create the file frontend/components/CourseForm.jsx and add the following code:

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 send a POST request to create a new course or a PUT request to update an existing one.

Now inside the frontend/app/manage-courses/page.jsx file, import the form we created above:

import CourseForm from "@/components/CourseForm";
// replace the modal content placeholder with below
<CourseForm onClose={handleCloseModal} setCourses={setCourses}

Now users should be able to create a new course.

create courses

Create Page for Course Editing

For editing the course, we will create a separate page.

Create a frontend/app/manage-courses/[slug]/page.jsx file and add the following code:

"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 populate the form. When a 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

Create Sections with Accordion

We will use Shadcn/UI accordion to make it collapsible and cleaner UI.

npx shadcn@latest add accordion

Modify the useEffect function 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]);

Create Section Utils

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`
});
}
};

Here is what the code above does:

  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 lectures in a 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 a user can manage lectures easily.

handle lectures
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 configure Strapi 5 and use it as a backend. How to create collection, it's relations and then create an entry.

We also learned how to build functional frontend for Udemy clone with Next.js.

In the next part, we will learn how to implement user authentication, manage courses page & user progress.

You can find the full code in https://github.com/ZehaIrawan/strapi-udemy-clone