Introduction
This is part one of this blog series, where we'll learn how to build a YouTube clone. In this part 1, we'll set up the Strapi CMS backend with collections, create data relationships, create custom endpoints for liking, commenting, and viewing videos, set up Socket.io, and create lifecycle methods to listen to real-time updates on Strapi collections.
For reference, here's the outline of this blog series:
- Part 1: Building a Video Streaming Backend with Strapi
- Part 2: Creating the App Services and State Management
- Part 3: Building the App UI with Flutter
Prerequisites
Before we dive in, ensure you have the following:
- Node and npm are installed on your computer.
- Postman for API testing.
- Flutter SDK installed.
- You should be familiar with Flutter and Strapi CMS CRUD operations. You can check out How to Build a Simple CRUD Application Using Flutter & Strapi.
Project Structure
Below is the folder structure for the app we'll be building throughout this tutorial.
📦youtube_clone
┣ 📂config: Configuration for the app.
┃ ┣ 📜admin.ts: Admin settings.
┃ ┣ 📜api.ts: API configuration.
┃ ┣ 📜database.ts: Database connection settings.
┃ ┣ 📜middlewares.ts: Middleware configuration.
┃ ┣ 📜plugins.ts: Plugin settings.
┃ ┣ 📜server.ts: Server settings.
┃ ┗ 📜socket.ts: Socket configuration.
┣ 📂database: Database setup files.
┃ ┃ ┣ 📂users-permissions: User permissions config.
┃ ┃ ┃ ┗ 📂content-types: User data structure.
┃ ┣ 📂utils: Utility functions.
┃ ┃ ┗ 📜emitEvent.ts: Event emitter utility.
┃ ┗ 📜index.ts: App's main entry point.
┣ 📂types: TypeScript type definitions.
┃ ┗ 📂generated: Auto-generated type files.
┃ ┃ ┣ 📜components.d.ts: Component types.
┃ ┃ ┗ 📜contentTypes.d.ts: Content type definitions.
┣ 📜.env: Environment variables.
Why Use Strapi and Flutter?
When building a video Streaming app, the developer is required to carefully select the right technologies that will provide an uninterrupted user experience. For the backend, the developer is required to use a scalable infrastructure because chat applications usually have high traffic (different users making concurrent real-time requests), the user base grows faster, and they generate a large amount of data.
Strapi 5 comes with an effective, scalable, headless CMS that is flexible and easy to use. It allows developers to structure their content, manage their media, and build complex data relationships, eliminating all the overheads from traditional backend development.
On the user end, Flutter provides a great solution for easily developing visually pleasant and highly responsive mobile applications. It improves the development of highly performant applications with a different feel and looks for both iOS and Android platforms, supported by its extensive pre-designed widget collection and robust ecosystem.
Overview of the App We'll Build
In this series, we’ll be building a video streaming app that allows users to upload videos, view a feed of videos, and interact with content through likes and comments.
The app will feature:
- Authentication: User authentication will be implemented using Strapi v5 authentication to support user registration, login, and profile management.
- Upload/Streaming Videos: Users can upload and stream videos to the media library in Strapi v5.
- Real-time Interactions: Users can comment, post, like, and observe videos updating in real-time by leveraging Socket.IO.
- Video Search and Discovery: The app allows users to search for videos they want to discover.
Below is a demo of what we will build by the end of this blog series.Link
Setting Up the Backend with Strapi
Let's start by setting up a Strapi 5 project for our backend. Create a new project by running the command below:
npx create-strapi-app@rc my-project --quickstart
The above command will scaffold a new Strapi 5 project and install the required Node.js dependencies. Strapi uses SQL database as the default database management system. We'll stick with that for the demonstrations in this tutorial.
Once the installation is completed, the project will run and automatically open on your browser at http://localhost:1337
.
Now, fill out the form to create your first Strapi administration account and authenticate to the Strapi Admin Panel.
Modeling Data in Strapi
Strapi allows you to create and manage your database model from the Admin panel. We'll create a Video and Comment collections to save the video data and users' comments on videos. To do that, click on the Content-Type Builder -> Create new collection type tab from your Admin panel to create a Video collection for your application and click Continue.
Then add the following fields to Video collection:
Field | Type |
---|---|
title |
Short Text field |
description |
Long Text field |
thumbnail |
Single Media field |
video_file |
Single Media field |
Then click the Save button.
Next, click Create new collection type to create a Comment collection and click Continue.
Add a text
field (short text) to the Comment collection and click the Save button. Lastly, click on User -> Add another field -> Media and add a new field named profile_picture
to allow users to upload their profile pictures when creating an account on the app.
Creating Data Relationships
I left out some fields in our Video and Comments collections because they are relations fields. I needed us to cover them separately. For the Video Collection, the fields are:
uploader
views
comments
-
likes
For the Comment collection, the fields are:
-
video
(the video commented on) -
user
(the user who posted the comment).
Adding Relation Field Between Video and Comment Collection Types
To add the relation fields to the Video collection, click on the Video -> Add new fields from Content-Type Builder page. Select a Relations from the fields modal, and add a new relation field named comments
, which will be a many-to-many relationships with the User collection. This is so that a user can comment on other users' videos, and another can also comment on their videos. Now click on the Finish button to save the changes.
Select Relation for Video and Comment Collection
A video can have many comments
Repeat this process to create the relation field for the uploader
, likes
, and views
fields. Your Video collection should look like the screenshot below:
Adding Relation Field in Comment Collection
For the Comment collection, click on the Comment -> Add new fields from the Content-Type Builder page. Select a Relation from the fields modal and add a new relation field named user
, which will be a many-to-many relationship with the User collection. Then click on the Finish button to save the changes.
Create Comment and User relationship
A User can have multiple comments
To create the video
relation field, you must also repeat this process. After the fields, your Comment collection will look like the screenshot below:
Adding New Relation Field to User Collection
Now update your User Collection to add a new relation field named subscribers
to save users' video subscribers. Click User -> Add new fields from the Content-Type Builder page, select the Relation field, and enter subscribers
, which is also a many-to-many relation with the User collection. On the left side, name the field subscribers
, and on the right, which is the User
collection, name it user_subscribers
since they are related to the same collection.
Creating Custom Controllers for Like, Views, and Comments.
With collections and relationships created, let's create custom controllers in our Strapi 5 backend to allow users to like, subscribe, and track users who viewed videos. In your Strapi project, open the video/controllers/video.ts
file, and extend your Strapi controller to add the like functionality with the code:
import { factories } from "@strapi/strapi";
export default factories.createCoreController(
"api::video.video",
({ strapi }) => ({
async like(ctx) {
try {
const { id } = ctx.params;
const user = ctx.state.user;
if (!user) {
return ctx.forbidden("User must be logged in");
}
// Fetch the video with its likes
const video: any = await strapi.documents("api::video.video").findOne({
documentId: id,
populate: ["likes"],
});
if (!video) {
return ctx.notFound("Video not found");
}
// Check if the user has already liked this video
const hasAlreadyLiked = video.likes.some((like) => like.id === user.id);
let updatedVideo;
if (hasAlreadyLiked) {
// Remove the user's like
video.updatedVideo = await strapi
.documents("api::video.video")
.update({
documentId: id,
data: {
likes: video.likes.filter(
(like: { documentId: string }) =>
like.documentId !== user.documentId,
),
},
populate: ["likes"],
});
} else {
// Add the user's like
updatedVideo = await strapi.documents("api::video.video").update({
documentId: id,
populate:"likes",
data: {
likes: [...video.likes, user.documentId] as any,
},
});
}
return ctx.send({
data: updatedVideo,
});
} catch (error) {
return ctx.internalServerError(
"An error occurred while processing your request",
);
}
},
);
The above code fetches the video the user wants to like and checks if the user has already liked it. If true, it unlikes the video by removing it from the array of likes for that video. Otherwise, it adds a new user object to the array of likes for the video and writes to the database for both cases to update the video records.
Then add the code below to the video controller for the views functionality:
//....
export default factories.createCoreController(
//....
async incrementView(ctx) {
try {
const { id } = ctx.params;
const user = ctx.state.user;
if (!user) {
return ctx.forbidden("User must be logged in");
}
// Fetch the video with its views
const video: any = await strapi.documents("api::video.video").findOne({
documentId: id,
populate: ["views", "uploader"],
});
if (!video) {
return ctx.notFound("Video not found");
}
// Check if the user is the uploader
if (user.id === video.uploader.id) {
return ctx.send({
message: "User is the uploader, no view recorded.",
});
}
// Get the current views
const currentViews =
video.views.map((view: { documentId: string }) => view.documentId) ||
[];
// Check if the user has already viewed this video
const hasAlreadyViewed = currentViews.includes(user.documentId);
if (hasAlreadyViewed) {
return ctx.send({ message: "User has already viewed this video." });
}
// Add user ID to the views array without removing existing views
const updatedViews = [...currentViews, user.documentId];
// Update the video with the new views array
const updatedVideo: any = await strapi
.documents("api::video.video")
.update({
documentId: id,
data: {
views: updatedViews as any,
},
});
return ctx.send({ data: updatedVideo });
} catch (error) {
console.error("Error in incrementView function:", error);
return ctx.internalServerError(
"An error occurred while processing your request",
);
}
},
);
The above code fetches the video clicked by the user by calling the strapi.service("api::video.video").findOne
method, which checks if the video has been liked by the video before, to avoid a case where a user likes a video twice. If the check is true it will simply send a success message, else it will update the video record to add the user object to the array of likes and write to the database by calling the strapi.service("api::video.video").update
method.
Lastly, add the code to implement the subscribe functionality to allow users to subscribe to channels they find interesting:
//....
export default factories.createCoreController(
//....
async subscribe(ctx) {
try {
const { id } = ctx.params;
const user = ctx.state.user;
if (!user) {
return ctx.forbidden("User must be logged in");
}
// Fetch the uploader and populate the subscribers relation
const uploader = await strapi.db
.query("plugin::users-permissions.user")
.findOne({
where: { id },
populate: ["subscribers"],
});
if (!uploader) {
return ctx.notFound("Uploader not found");
}
// Check if the user is already subscribed
const isSubscribed =
uploader.subscribers &&
uploader.subscribers.some(
(subscriber: { id: string }) => subscriber.id === user.id,
);
let updatedSubscribers;
if (isSubscribed) {
// If subscribed, remove the user from the subscribers array
updatedSubscribers = uploader.subscribers.filter(
(subscriber) => subscriber.id !== user.id,
);
} else {
// If not subscribed, add the user to the subscribers array
updatedSubscribers = [...uploader.subscribers, user.id];
}
// Update the uploader with the new subscribers array
const updatedUploader = await strapi
.query("plugin::users-permissions.user")
.update({
where: { id },
data: {
subscribers: updatedSubscribers,
},
});
return ctx.send({
message: isSubscribed
? "User has been unsubscribed from this uploader."
: "User has been subscribed to this uploader.",
data: updatedUploader,
});
} catch (error) {
console.error("Error in subscribe function:", error);
return ctx.internalServerError(
"An error occurred while processing your request",
);
}
},
}),
);
The above code fetches the details of the channel owner using the Strapi users permission plugin. plugin::users-permissions.user
. After that, it uses the strapi.db.query("plugin::users-permissions.user").findOne
method to check if the subscriber user id is present in the subscriber's array, if true, it removes the user from the array of subscribers. Else, it adds the user to the array of subscribers user objects.
Creating Custom Endpoints for Like, Views, and Comments.
Next, create a new file named custom-video.ts
in the video/routes
folder and add the following custom endpoints for the controllers we defined earlier:
export default {
routes: [
{
method: 'PUT',
path: '/videos/:id/like',
handler: 'api::video.video.like',
config: {
policies: [],
middlewares: [],
},
},
{
method: 'PUT',
path: '/videos/:id/increment-view',
handler: 'api::video.video.incrementView',
config: {
policies: [],
middlewares: [],
},
},
{
method: 'PUT',
path: '/videos/:id/subscribe',
handler: 'api::video.video.subscribe',
config: {
policies: [],
middlewares: [],
},
},
],
};
The above code defines custom routes for like
, incrementView
, and subscribe
. The endpoint can be accessed at http://localhost:1337/api/videos/:id/like
, http://localhost:1337/api/videos/:id/like
, and http://localhost:1337/api/videos/:id/like
, respectively.
Configuring Users & Permissions
Strapi provides authorization for your collections out of the box, you only need to specify what kind of access you give users. To do this, navigate to Settings -> Users & Permissions plugin -> Role.
Here you will find two user roles:
- Authenticated: A user with this role will have to be authenticated to perform some certain roles. Users in this category normally receive more enabled functionality than users in the Public category.
- Public: This role is assigned to users who are not logged in or authenticated.
For the Authenticated role, give the following access to the collections:
Collection | Access |
---|---|
Comments |
find , create , findOne and update
|
Videos |
create , incrementView , subscribe , update , find , findOne , and like
|
Upload | upload |
Users-permissions(User) |
find , findOne , update , me
|
Then give the Public role the following access to the collections:
Collection | Access |
---|---|
Comments |
find and findOne
|
Videos |
find and findOne
|
Upload | upload |
Users-permissions (User) |
find and findOne
|
The above configurations allow the Public role (unauthenticated user) to view videos and the details of the user who uploaded the video, such as username, profile picture, and number of subscribers. We also gave it access to see comments on videos and upload files because users must upload a profile during sign-up. For the Authenticated role, we gave it more access to comment, like, subscribe, and update their user details.
Implementing Real-time Features with Socket.IO
We need to allow users to get real-time updates when a new video, comment, or like is created or when a video is updated. To do this, we'll use Socket.IO. We'll write a custom Socket implementation in our Strapi project to handle real-time functionalities.
First, install Socket.IO in your Strapi project by running the command below:
npm install socket.io
Then, create a new folder named socket
in the api
directory for the socket API. In the api/socket
directory, create a new folder named services
and a socket.ts
file in the services
folder. Add the code snippets below to setup and initialize a socket connection:
import { Core } from "@strapi/strapi";
export default ({ strapi }: { strapi: Core.Strapi }) => ({
initialize() {
strapi.eventHub.on('socket.ready', async () => {
const io = (strapi as any).io;
if (!io) {
strapi.log.error("Socket.IO is not initialized");
return;
}
io.on("connection", (socket: any) => {
strapi.log.info(`New client connected with id ${socket.id}`);
socket.on("disconnect", () => {
strapi.log.info(`Client disconnected with id ${socket.id}`);
});
});
strapi.log.info("Socket service initialized successfully");
});
},
emit(event: string, data: any) {
const io = (strapi as any).io;
if (io) {
io.emit(event, data);
} else {
strapi.log.warn("Attempted to emit event before Socket.IO was ready");
}
},
});
Then update your src/index.ts
file to initialize the Socket.IO server, set up event listeners for user updates and creations, and integrate the socket service with Strapi's lifecycle hooks:
import { Core } from "@strapi/strapi";
import { Server as SocketServer } from "socket.io";
import { emitEvent, AfterCreateEvent } from "./utils/emitEvent";
interface SocketConfig {
cors: {
origin: string | string[];
methods: string[];
};
}
export default {
register({ strapi }: { strapi: Core.Strapi }) {
const socketConfig = strapi.config.get("socket.config") as SocketConfig;
if (!socketConfig) {
strapi.log.error("Invalid Socket.IO configuration");
return;
}
strapi.server.httpServer.on("listening", () => {
const io = new SocketServer(strapi.server.httpServer, {
cors: socketConfig.cors,
});
(strapi as any).io = io;
strapi.eventHub.emit("socket.ready");
});
},
bootstrap({ strapi }: { strapi: Core.Strapi }) {
const socketService = strapi.service("api::socket.socket") as {
initialize: () => void;
};
if (socketService && typeof socketService.initialize === "function") {
socketService.initialize();
} else {
strapi.log.error("Socket service or initialize method not found");
}
},
};
The above code sets up the Socket.IO configuration, creates a new SocketServer
instance when the HTTP server starts listening, and subscribes to database lifecycle events for User collection to emit real-time updates.
Next, create a new folder named utils
in the src
folder. In the utils
folder, create an emitEvents.ts
file and add the code snippets below to define an emitEvent
function:
import type { Core } from "@strapi/strapi";
interface AfterCreateEvent {
result: any;
}
function emitEvent(eventName: string, event: AfterCreateEvent) {
const { result } = event;
const strapi = global.strapi as Core.Strapi;
const socketService = strapi.service("api::socket.socket");
if (socketService && typeof (socketService as any).emit === "function") {
(socketService as any).emit(eventName, result);
} else {
strapi.log.error("Socket service or emit method not found");
}
}
export { emitEvent, AfterCreateEvent };
This function emits socket events when certain database actions occur. It takes an event name and an AfterCreateEvent
object as parameters, extracts the result from the event, and uses the socket service to emit the event with the result data.
Creating Lifecycles methods for Video Collection
Now let's use the lifecycle method for the Video and Comment collection methods to listen to create, update, and delete events. Create a lifecycles.ts
file in the api/video/content-type/video
folder and add the code below:
import { emitEvent, AfterCreateEvent } from "../../../../utils/emitEvent";
export default {
async afterUpdate(event: AfterCreateEvent) {
emitEvent("video.updated", event);
},
async afterCreate(event: AfterCreateEvent) {
emitEvent("video.created", event);
},
async afterDelete(event: AfterCreateEvent) {
emitEvent("video.deleted", event);
},
};
Creating Lifecycles methods for Comment Collection
Next, create a lifecycles.ts
file in the api/comment/content-type/comment
folder and add the code below:
import { emitEvent, AfterCreateEvent } from "../../../../utils/emitEvent";
export default {
async afterCreate(event: AfterCreateEvent) {
emitEvent("comment.created", event);
},
async afterUpdate(event: AfterCreateEvent) {
emitEvent("comment.updated", event);
},
async afterDelete(event: AfterCreateEvent) {
emitEvent("comment.deleted", event);
},
};
Lastly, update the bootstrap
function in your src/index.ts
file to listen to create and update events in the users-permissions.user
plugin:
// ...
bootstrap({ strapi }: { strapi: Core.Strapi }) {
//...
strapi.db.lifecycles.subscribe({
models: ["plugin::users-permissions.user"],
async afterUpdate(event) {
emitEvent("user.updated", event as AfterCreateEvent);
},
async afterCreate(event) {
emitEvent("user.created", event as AfterCreateEvent);
},
});
},
Testing Events and Real Time Updates with Postman
To test this out, open a new Postman Socket.io window.
Enable Events
Then, connect to your Strapi backend by entering the Strapi API URL. Click the Events tab and enter the lifecycle events we created in the Strapi backend. Check the Listen boxes for all the events you want to monitor, and click the Connect button.
Create Entries to See Events Emitted
Now return to your Strapi Admin panel, navigate to Content Manager -> Video -> + Create new entries, and create new video entries.
Once you perform actions in your Strapi admin panel that trigger the lifecycle events you've set, such as creating, updating, and deleting your collections, you should see notifications show up in Postman. This will enable you to verify that your Strapi backend emits events and that your WebSocket connection functions as expected.
We're done with part one of this blog series. Stay tuned for Part 2, where we'll continue this tutorial by building the frontend with Flutter and consuming the APIs to implement a functional YouTube clone application. The code for this Strapi backend is available on my Github repository. We've split the tutorial into three parts, each in its own branch for easier navigation. The main branch contains the Strapi code. The part_2 branch holds the Flutter code for state management and app services, but if you run it, you'll only see the default Flutter app since these logics are connected to the UI in part_3. The part_3 branch contains the full Flutter code with both the UI and logic integrated.
Conclusion
In part one of this tutorial series, we learned how to set up the Strapi backend with collections, create data relationships, create custom endpoints for liking, commenting, and viewing videos, set up Socket.io, and create lifecycle methods to listen to real-time updates on the collections.
In the next part, we will learn how to build the frontend with Flutter.