DEV Community

Cover image for Build a Youtube Clone with Strapi and Flutter: Part 1
Strapi for Strapi

Posted on • Originally published at strapi.io

Build a Youtube Clone with Strapi and Flutter: Part 1

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:

Prerequisites

Before we dive in, ensure you have the following:

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.
Enter fullscreen mode Exit fullscreen mode

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

final.gif

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
Enter fullscreen mode Exit fullscreen mode

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.

welcome to strapi.png

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.

create video collection in Strapi.png

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.

video fields .png

Next, click Create new collection type to create a Comment collection and click Continue.

comment collection.png

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.

add media to collection in strapi.png

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.png
Select Relation for Video and Comment Collection

A video can have many comments.png
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:

video collection fields.png

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.png
Create Comment and User relationship

A User can have multiple comments Comment Suggest edit Edit from here  .png
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:

comment collection fields.png

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.

Adding New Relation Field to User Model.png

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",
        );
      }
    },
);
Enter fullscreen mode Exit fullscreen mode

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",
        );
      }
    },
 );
Enter fullscreen mode Exit fullscreen mode

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",
        );
      }
    },
  }),  
);
Enter fullscreen mode Exit fullscreen mode

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: [],
        },
      },
    ],
  };
Enter fullscreen mode Exit fullscreen mode

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.

Configuring Users & Permissions.png

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
Enter fullscreen mode Exit fullscreen mode

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");
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

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");
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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);
  },
};
Enter fullscreen mode Exit fullscreen mode

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);
  },
};
Enter fullscreen mode Exit fullscreen mode

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);
      },
    });
  },
Enter fullscreen mode Exit fullscreen mode

Testing Events and Real Time Updates with Postman

To test this out, open a new Postman Socket.io window.

test on postman.png

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.

enable events on postman.png

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.

create video entry.png

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.

event emitted.png

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.

Top comments (0)