DEV Community

Cover image for Build a Google Meet Clone with Strapi 5 and Next.js - Part 2
Strapi for Strapi

Posted on • Originally published at strapi.io

Build a Google Meet Clone with Strapi 5 and Next.js - Part 2

Welcome to the final part of our Google Meet clone tutorial series! In this part, we'll implement video conferencing, screen sharing, and real-time chat functionality using WebRTC and Socket.io.

For reference purposes, here's the outline of this blog series:

Before starting, ensure you have:

Installing Dependencies

To continue from where we left off in Part 1, let's add the required WebRTC and Socket.io packages by running the command below in your frontend project folder:

npm install socket.io-client @types/webrtc simple-peer @types/simple-peer
Enter fullscreen mode Exit fullscreen mode

Here is what we installed inside our frontend project:

  • socket.io-client: Realtime application framework client
  • @types/webrtc: TypeScript definitions for webrtc.
  • simple-peer: The mobile web app connects groups of up to four people in a peer-to-peer WebRTC audio and video call so that they can mutually prove unique personhood.
  • @types/simple-peer: TypeScript definitions for simple-peer.

For the Strapi backend, install the Socket.io package:

cd ../google-meet-clone-backend
npm install socket.io
Enter fullscreen mode Exit fullscreen mode

Setting Up WebSocket Server

First, create a socket.ts file in the config folder and add the code below:

export default ({ env }) => ({
  enabled: true,
  config: {
    port: env.int("SOCKET_PORT", 1337),
    cors: {
      origin: env("SOCKET_CORS_ORIGIN", "*"),
      methods: ["GET", "POST"],
    },
  },
});
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.

Create Event Listeners for Real-time Peer-to-Peer Connection in Strapi

Add the code snippets below in the api/socket/services/socket.ts file to set and initialize a socket connection, and create all the event listeners we need to communicate with our Next.js client for real-time Peer to Peer connection:

import { Core } from "@strapi/strapi";

interface MeetingParticipant {
  socketId: string;
  username: string;
}

interface Meeting {
  participants: Map<string, MeetingParticipant>;
  lastActivity: number;
}

export default ({ strapi }: { strapi: Core.Strapi }) => {
  // Store active meetings and their participants
  const activeMeetings = new Map<string, Meeting>();

  // Cleanup inactive meetings periodically
  const cleanupInterval = setInterval(
    () => {
      const now = Date.now();
      activeMeetings.forEach((meeting, meetingId) => {
        if (now - meeting.lastActivity > 1000 * 60 * 60) {
          // 1 hour timeout
          activeMeetings.delete(meetingId);
        }
      });
    },
    1000 * 60 * 15
  );

  return {
    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) => {
          const { meetingId, userId } = socket.handshake.query;
          strapi.log.info(
            `Client connected - Socket: ${socket.id}, User: ${userId}, Meeting: ${meetingId}`
          );

          // Initialize meeting if it doesn't exist
          if (!activeMeetings.has(meetingId)) {
            activeMeetings.set(meetingId, {
              participants: new Map(),
              lastActivity: Date.now(),
            });
          }

          socket.on("join-meeting", async ({ meetingId, userId }) => {
            try {
              // Get user data with username
              const user = await strapi
                .query("plugin::users-permissions.user")
                .findOne({
                  where: { id: userId },
                  select: ["id", "username"],
                });

              strapi.log.info(`User ${userId} joining meeting ${meetingId}`);

              const meeting = activeMeetings.get(meetingId);
              if (!meeting) return;

              // Add participant to meeting with both ID and username
              meeting.participants.set(userId.toString(), {
                socketId: socket.id,
                username: user.username,
              });
              meeting.lastActivity = Date.now();

              // Join socket room
              socket.join(meetingId);

              // Get current participants with their usernames
              const currentParticipants = Array.from(
                meeting.participants.entries()
              )
                .filter(([id]) => id !== userId.toString())
                .map(([id, data]) => ({
                  userId: id,
                  username: data.username,
                }));

              // Send current participants to the joining user
              socket.emit("participants-list", currentParticipants);

              // Notify others about the new participant
              socket.to(meetingId).emit("user-joined", {
                userId: userId.toString(),
                username: user.username,
              });

              strapi.log.info(
                `Current participants in meeting ${meetingId}:`,
                Array.from(meeting.participants.entries()).map(
                  ([id, data]) => ({
                    id,
                    username: data.username,
                  })
                )
              );
            } catch (error) {
              strapi.log.error("Error in join-meeting:", error);
            }
          });

          socket.on("chat-message", ({ message, meetingId }) => {
            socket.to(meetingId).emit("chat-message", message);
          });

          const meeting = activeMeetings.get(meetingId);
          if (!meeting) return;

          socket.on("signal", ({ to, from, signal }) => {
            console.log(
              `Forwarding ${signal.type} signal from ${from} to ${to}`
            );
            const targetSocket = meeting.participants.get(
              to.toString()
            )?.socketId;
            if (targetSocket) {
              io.to(targetSocket).emit("signal", {
                signal,
                userId: from.toString(),
              });
            } else {
              console.log(`No socket found for user ${to}`);
            }
          });
          const handleDisconnect = () => {
            const meeting = activeMeetings.get(meetingId);
            if (!meeting) return;

            // Find and remove the disconnected user
            const disconnectedUserId = Array.from(
              meeting.participants.entries()
            ).find(([_, socketId]) => socketId === socket.id)?.[0];

            if (disconnectedUserId) {
              meeting.participants.delete(disconnectedUserId);
              meeting.lastActivity = Date.now();

              // Notify others about the user leaving
              socket.to(meetingId).emit("user-left", {
                userId: disconnectedUserId,
              });

              strapi.log.info(
                `User ${disconnectedUserId} left meeting ${meetingId}`
              );
              strapi.log.info(
                `Remaining participants:`,
                Array.from(meeting.participants.keys())
              );

              // Clean up empty meetings
              if (meeting.participants.size === 0) {
                activeMeetings.delete(meetingId);
                strapi.log.info(
                  `Meeting ${meetingId} closed - no participants remaining`
                );
              }
            }
          };

          socket.on("disconnect", handleDisconnect);
          socket.on("leave-meeting", handleDisconnect);
        });

        strapi.log.info("Conference socket service initialized successfully");
      });
    },

    destroy() {
      clearInterval(cleanupInterval);
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

Initialize Socket.io Server with Strapi

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:

import { Core } from "@strapi/strapi";
import { Server as SocketServer } from "socket.io";

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

Implementing Video Meeting Page

With our socket connection and events created, let's create a real-time video meeting page to handle video conference rooms to allow users to have video meetings.

Create Page for Video Conference Room

Create a new page for the video conference room in src/app/meetings/[id]/page.tsx file and add the code snippets below:

'use client'

import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import SimplePeer from "simple-peer"
import { io, Socket } from "socket.io-client"
import { getCookie } from "cookies-next"
import { User } from "@/types"

interface ExtendedSimplePeer extends SimplePeer.Instance {
  _pc: RTCPeerConnection
}

interface Peer {
  peer: SimplePeer.Instance
  userId: string
  stream?: MediaStream
}

export default function ConferenceRoom() {
  const params = useParams()
  const [peers, setPeers] = useState<Peer[]>([])
  const [stream, setStream] = useState<MediaStream | null>(null)
  const socketRef = useRef<Socket>()
  const userVideo = useRef<HTMLVideoElement>(null)
  const peersRef = useRef<Peer[]>([])
  const [user, setUser] = useState<User | null>(null)
  const [isConnected, setIsConnected] = useState(false)
  const [screenStream, setScreenStream] = useState<MediaStream | null>(null)
  const [isScreenSharing, setIsScreenSharing] = useState(false)

  useEffect(() => {
    try {
      const cookieValue = getCookie("auth-storage")
      if (cookieValue) {
        const parsedAuthState = JSON.parse(String(cookieValue))
        setUser(parsedAuthState.state.user)
      }
    } catch (error) {
      console.error("Error parsing auth cookie:", error)
    }
  }, [])

  useEffect(() => {
    if (!user?.id || !params.id) return

    const cleanupPeers = () => {
      peersRef.current.forEach((peer) => {
        if (peer.peer) {
          peer.peer.destroy()
        }
      })
      peersRef.current = []
      setPeers([])
    }

    cleanupPeers()

    socketRef.current = io(process.env.NEXT_PUBLIC_STRAPI_URL || "", {
      query: { meetingId: params.id, userId: user.id },
      transports: ["websocket"],
      reconnection: true,
      reconnectionAttempts: 5,
    })

    socketRef.current.on("connect", () => {
      setIsConnected(true)
      console.log("Socket connected:", socketRef.current?.id)
    })

    socketRef.current.on("disconnect", () => {
      setIsConnected(false)
      console.log("Socket disconnected")
    })

    navigator.mediaDevices
      .getUserMedia({ video: true, audio: true })
      .then((stream) => {
        setStream(stream)
        if (userVideo.current) {
          userVideo.current.srcObject = stream
        }

        socketRef.current?.emit("join-meeting", {
          userId: user.id,
          meetingId: params.id,
        })

        socketRef.current?.on("signal", ({ userId, signal }) => {
          console.log("Received signal from:", userId, "Signal type:", signal.type)
          let peer = peersRef.current.find((p) => p.userId === userId)

          if (!peer && stream) {
            console.log("Creating new peer for signal from:", userId)
            const newPeer = createPeer(userId, stream, false)
            peer = { peer: newPeer, userId }
            peersRef.current.push(peer)
            setPeers([...peersRef.current])
          }

          if (peer) {
            try {
              peer.peer.signal(signal)
            } catch (err) {
              console.error("Error processing signal:", err)
            }
          }
        })

        socketRef.current?.on("participants-list", (participants) => {
          console.log("Received participants list:", participants)

          cleanupPeers()
          setPeers([...peersRef.current])
        })

        socketRef.current?.on("user-joined", ({ userId, username }) => {
          console.log("New user joined:", userId)
          if (userId !== user?.id.toString()) {

            if (stream && !peersRef.current.find((p) => p.userId === userId)) {
              console.log("Creating non-initiator peer for new user:", userId)
              const peer = createPeer(userId, stream, false)
              peersRef.current.push({ peer, userId })
              setPeers([...peersRef.current])
            }
          }
        })

        socketRef.current?.on("user-left", ({ userId }) => {
          console.log("User left:", userId)
          const peerIndex = peersRef.current.findIndex((p) => p.userId === userId)
          if (peerIndex !== -1) {
            peersRef.current[peerIndex].peer.destroy()
            peersRef.current.splice(peerIndex, 1)
            setPeers([...peersRef.current])
          }
        })
      })
      .catch((error) => {
        console.error("Error accessing media devices:", error)
      })

    return () => {

      if (socketRef.current) {
        socketRef.current.emit("leave-meeting", {
          userId: user?.id,
          meetingId: params.id,
        })

        socketRef.current.off("participants-list")
        socketRef.current.off("user-joined")
        socketRef.current.off("user-left")
        socketRef.current.off("signal")
        socketRef.current.disconnect()
      }

      if (stream) {
        stream.getTracks().forEach((track) => track.stop())
      }

      cleanupPeers()
    }
  }, [user?.id, params.id])

  useEffect(() => {
    if (!socketRef.current) return

    socketRef.current.on("media-state-change", ({ userId, type, enabled }) => {
    })

    return () => {
      socketRef.current?.off("media-state-change")
    }
  }, [socketRef.current])

  function createPeer(userId: string, stream: MediaStream, initiator: boolean): SimplePeer.Instance {
    console.log(`Creating peer connection - initiator: ${initiator}, userId: ${userId}`)

    const peer = new SimplePeer({
      initiator,
      trickle: false,
      stream,
      config: {
        iceServers: [
          { urls: "stun:stun.l.google.com:19302" },
          { urls: "stun:global.stun.twilio.com:3478" },
        ],
      },
    })

    peer.on("signal", (signal) => {
      console.log(`Sending signal to ${userId}, type: ${signal.type}`)
      socketRef.current?.emit("signal", {
        signal,
        to: userId,
        from: user?.id,
      })
    })

    peer.on("connect", () => {
      console.log(`Peer connection established with ${userId}`)
    })

    peer.on("stream", (incomingStream) => {
      console.log(`Received stream from ${userId}, tracks:`, incomingStream.getTracks())
      const peerIndex = peersRef.current.findIndex((p) => p.userId === userId)
      if (peerIndex !== -1) {
        peersRef.current[peerIndex].stream = incomingStream
        setPeers([...peersRef.current])
      }
    })

    peer.on("error", (err) => {
      console.error(`Peer error with ${userId}:`, err)
      const peerIndex = peersRef.current.findIndex((p) => p.userId === userId)
      if (peerIndex !== -1) {
        peersRef.current[peerIndex].peer.destroy()
        peersRef.current.splice(peerIndex, 1)
        setPeers([...peersRef.current])
      }
    })

    peer.on("close", () => {
      console.log(`Peer connection closed with ${userId}`)
    })

    return peer
  }

  return (
    <div className="flex flex-col gap-4 p-4">
      <div className="text-sm text-gray-500">
        {isConnected ? "Connected to server" : "Disconnected from server"}
      </div>
      {/* <ParticipantList /> */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        <div className="relative">
          <video
            ref={userVideo}
            autoPlay
            muted
            playsInline
            className="w-full rounded-lg bg-gray-900"
          />
          <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
            You
          </div>
        </div>
        {peers.map(({ peer, userId, stream }) => (
          <PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
        ))}
      </div>
    </div>
  )
}

function PeerVideo({ peer, userId, stream }: { peer: SimplePeer.Instance; userId: string; stream?: MediaStream }) {
  const ref = useRef<HTMLVideoElement>(null)

  useEffect(() => {
    if (stream && ref.current) {
      ref.current.srcObject = stream
    }

    const handleStream = (incomingStream: MediaStream) => {
      if (ref.current) {
        ref.current.srcObject = incomingStream
      }
    }

    peer.on("stream", handleStream)

    return () => {
      if (ref.current) {
        ref.current.srcObject = null
      }
      peer.off("stream", handleStream)
    }
  }, [peer, stream])

  return (
    <div className="relative">
      <video ref={ref} autoPlay playsInline className="w-full rounded-lg bg-gray-900" />
      <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Here is what the code above does:

  • The above code handles the WebRTC and Socket.IO peer-to-peer video chat. It uses SimplePeer to handle WebRTC connections, maintaining a peers state array, and peersRef to track all connected users.
  • The component initializes by getting the user's video and audio stream using getUserMedia, then sets up socket connections with events like join-meeting, signal, user-joined, and user-left to handle real-time communication.
  • The createPeer function is the backbone, creating new peer connections with ice servers for NAT traversal while handling various peer events like signal, connect, stream, and error.
  • The video streams are displayed using the userVideo ref for the local user and a separate PeerVideo component for remote participants, which manages individual video elements and their streams. It uses socket.current for maintaining the WebSocket connection and handles cleanup using useEffect's return function, ensuring all peer connections are properly destroyed and media streams are stopped when the component unmounts.

Create Page for Video Conference Room.png

Implementing Screen Sharing

To allow users to share their screen while on the call, let's add screen-sharing functionality to the conference room:

//...
import { ScreenShare, StopScreenShare } from 'lucide-react';

interface ExtendedSimplePeer extends SimplePeer.Instance {
  _pc: RTCPeerConnection;
}

//...
export default function ConferenceRoom() {
  //...
  const [screenStream, setScreenStream] = useState<MediaStream | null>(null);
  const [isScreenSharing, setIsScreenSharing] = useState(false); 



  const toggleScreenShare = async () => {
    if (!isScreenSharing) {
      try {
        const screen = await navigator.mediaDevices.getDisplayMedia({
          video: true,
          audio: false,
        });

        // Handle when the user clicks the "Stop sharing" button in the browser
        screen.getVideoTracks()[0].addEventListener("ended", () => {
          stopScreenSharing();
        });

        setScreenStream(screen);
        setIsScreenSharing(true);

        // Replace video track for all peers
        peersRef.current.forEach(({ peer }) => {
          const videoTrack = screen.getVideoTracks()[0];
          const extendedPeer = peer as ExtendedSimplePeer;
          const sender = extendedPeer._pc
            .getSenders()
            .find((s) => s.track?.kind === "video");

          if (sender) {
            sender.replaceTrack(videoTrack);
          }
        });

        // Replace local video
        if (userVideo.current) {
          userVideo.current.srcObject = screen;
        }
      } catch (error) {
        console.error("Error sharing screen:", error);
      }
    } else {
      stopScreenSharing();
    }
  };

  const stopScreenSharing = () => {
    if (screenStream) {
      screenStream.getTracks().forEach((track) => track.stop());
      setScreenStream(null);
      setIsScreenSharing(false);

      // Revert to camera video for all peers
      if (stream) {
        peersRef.current.forEach(({ peer }) => {
          const videoTrack = stream.getVideoTracks()[0];
          const extendedPeer = peer as ExtendedSimplePeer;
          const sender = extendedPeer._pc
            .getSenders()
            .find((s) => s.track?.kind === "video");

          if (sender) {
            sender.replaceTrack(videoTrack);
          }
        });

        // Revert local video
        if (userVideo.current) {
          userVideo.current.srcObject = stream;
        }
      }
    }
  };

  //...

  return (
    <div className="flex flex-col gap-4 p-4">
      <div className="text-sm text-gray-500">
        {isConnected ? "Connected to server" : "Disconnected from server"}
      </div>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        <div className="relative">
          <video
            ref={userVideo}
            autoPlay
            muted
            playsInline
            className="w-full rounded-lg bg-gray-900"
          />
          <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
            You
          </div>
        </div>
        {peers.map(({ peer, userId, stream }) => (
            <PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
        ))}
      </div>
      {/* added this button to handle the start screen and stop sharing. */}

      <button
        onClick={toggleScreenShare}
        className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
        style={{ width: "15rem" }}
      >
        {isScreenSharing ? (
          <>
            <StopCircleIcon className="w-5 h-5" />
            Stop Sharing
          </>
        ) : (
          <>
            <ScreenShare className="w-5 h-5" />
            Share Screen
          </>
        )}
      </button>
      //
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the above code:

  • We added toggleScreenShare and stopScreenSharing functions, where toggleScreenShare uses navigator.mediaDevices.getDisplayMedia to capture the user's screen as a MediaStream, storing it in screenStream state and tracking its status with isScreenSharing.
  • When screen sharing is activated, it replaces the video tracks for all peer connections using RTCPeerConnection's getSenders().replaceTrack method, changing what each participant sees from camera to screen content.
  • The stopScreenSharing function handles the cleanup by stopping all screen-sharing tracks and reverting everyone to camera video.
  • Implementing Screen Sharing.png

Adding Real-Time Chat

Next, let's add a chat functionality to allow users to chat in real time while on the call.

Create Chat Component

Create a chat component in src/components/meeting/chat.tsx:

"use client";

import { useState, useEffect, useRef } from "react";
import { useAuthStore } from "@/store/auth-store";
import { Socket } from "socket.io-client";
import { User } from "@/types";

interface ChatProps {
  socketRef: React.MutableRefObject<Socket | undefined>; // Changed from RefObject to MutableRefObject
  user: User;
  meetingId: string;
}

interface Message {
  userId: string;
  username: string;
  text: string;
  timestamp: number;
}

function Chat({ socketRef, user, meetingId }: ChatProps) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [newMessage, setNewMessage] = useState("");
  const [isExpanded, setIsExpanded] = useState(true);
  const chatRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const socket = socketRef.current;
    if (!socket) return;

    const handleChatMessage = (message: Message) => {
      setMessages((prev) => [...prev, message]);
    };

    socket.on("chat-message", handleChatMessage);

    return () => {
      socket?.off("chat-message", handleChatMessage);
    };
  }, [socketRef.current]);

  useEffect(() => {
    if (chatRef.current) {
      chatRef.current.scrollTop = chatRef.current.scrollHeight;
    }
  }, [messages]);

  const sendMessage = (e: React.FormEvent) => {
    e.preventDefault();
    const socket = socketRef.current;
    if (!socket || !newMessage.trim()) return;

    const message: Message = {
      userId: user.id.toString(),
      username: user.username,
      text: newMessage,
      timestamp: Date.now(),
    };

    socket.emit("chat-message", {
      message,
      meetingId,
    });

    setMessages((prev) => [...prev, message]);
    setNewMessage("");
  };

  return (
    <div className="fixed right-4 bottom-4 w-80 bg-white rounded-lg shadow-lg flex flex-col border">
      <div
        className="p-3 border-b flex justify-between items-center cursor-pointer"
        onClick={() => setIsExpanded(!isExpanded)}
      >
        <h3 className="font-medium text-gray-600">Chat</h3>
        <button className="text-gray-500 hover:text-gray-700">
          {isExpanded ? "â–¼" : "â–²"}
        </button>
      </div>

      {isExpanded && (
        <>
          <div
            ref={chatRef}
            className="flex-1 overflow-y-auto p-4 space-y-4 max-h-96"
          >
            {messages.map((message, index) => (
              <div
                key={index}
                className={`flex ${
                  message.userId === user.id.toString()
                    ? "justify-end"
                    : "justify-start"
                }`}
              >
                <div
                  className={`max-w-xs px-4 py-2 rounded-lg ${
                    message.userId === user.id.toString()
                      ? "bg-blue-600 text-white"
                      : "bg-gray-400"
                  }`}
                >
                  {message.userId !== user.id.toString() && (
                    <p className="text-xs font-medium mb-1">
                      {message.username}
                    </p>
                  )}
                  <p className="break-words">{message.text}</p>
                  <span className="text-xs opacity-75 block mt-1">
                    {new Date(message.timestamp).toLocaleTimeString()}
                  </span>
                </div>
              </div>
            ))}
          </div>

          <form onSubmit={sendMessage} className="p-4 border-t">
            <div className="flex gap-2">
              <input
                type="text"
                value={newMessage}
                onChange={(e) => setNewMessage(e.target.value)}
                className="flex-1 px-3 py-2 border rounded-lg text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
                placeholder="Type a message..."
              />
              <button
                type="submit"
                className="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
                disabled={!newMessage.trim()}
              >
                Send
              </button>
            </div>
          </form>
        </>
      )}
    </div>
  );
}


export default Chat;
Enter fullscreen mode Exit fullscreen mode

Broadcast Chat to All Connected Clients with Strapi

Now update your Strapi socket service in your api/socket/services/socket.ts file to broadcast the chat to all connected clients in the meeting.

//...
socket.on("chat-message", ({ message, meetingId }) => {
   socket.to(meetingId).emit("chat-message", message);
});
//...
Enter fullscreen mode Exit fullscreen mode

Render Chat Component

Then update your app/meetings/[id]/page.tsx file to render the Chat component in your return statement:

//...
import Chat from "@/components/meeting/chat";

//...

export default function ConferenceRoom() {

  //...
  return (
    <div className="flex flex-col gap-4 p-4">
      <div className="text-sm text-gray-500">
        {isConnected ? "Connected to server" : "Disconnected from server"}
      </div>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        <div className="relative">
          <video
            ref={userVideo}
            autoPlay
            muted
            playsInline
            className="w-full rounded-lg bg-gray-900"
          />
          <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
            You
          </div>
        </div>
        {peers.map(({ peer, userId, stream }) => (
          <PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
        ))}
      </div>
      <button
        onClick={toggleScreenShare}
        className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
        style={{ width: "15rem" }}
      >
        {isScreenSharing ? (
          <>
            <StopCircleIcon className="w-5 h-5" />
            Stop Sharing
          </>
        ) : (
          <>
            <ScreenShare className="w-5 h-5" />
            Share Screen
          </>
        )}
      </button>
      <Chat
        socketRef={socketRef}
        user={user as User}
        meetingId={params.id as string}
      />{" "}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Render Chat Component.png

Setting up Meeting Controls

Now let's take things a little bit further and make our app look more like Google Meet.

Let's have a central control for video conferencing and add features like muting and unmuting the mic, turning the video off and on, screen sharing, and leaving the meeting.

Create The Controls Component

Create a controls component in src/components/meeting/controls.tsx:

import { useRouter } from 'next/navigation';
import {
  Mic,
  MicOff,
  Video,
  VideoOff,
  ScreenShare,
  StopCircleIcon,
  Phone,
} from 'lucide-react';
import { Socket } from 'socket.io-client';
import { useState } from 'react';

interface ControlsProps {
  stream: MediaStream | null;
  screenStream: MediaStream | null;
  isScreenSharing: boolean;
  socketRef: React.MutableRefObject<Socket | undefined>;
  peersRef: React.MutableRefObject<any[]>;
  meetingId: string;
  userId: string;
  onScreenShare: () => Promise<void>;
}

export default function Controls({
  stream,
  screenStream,
  isScreenSharing,
  socketRef,
  peersRef,
  meetingId,
  userId,
  onScreenShare,
}: ControlsProps) {
  const router = useRouter();
  const [isAudioEnabled, setIsAudioEnabled] = useState(true);
  const [isVideoEnabled, setIsVideoEnabled] = useState(true);

  const toggleAudio = () => {
    if (stream) {
      stream.getAudioTracks().forEach((track) => {
        track.enabled = !isAudioEnabled;
      });
      setIsAudioEnabled(!isAudioEnabled);

      // Notify peers about audio state change
      socketRef.current?.emit('media-state-change', {
        meetingId,
        userId,
        type: 'audio',
        enabled: !isAudioEnabled,
      });
    }
  };

  const toggleVideo = () => {
    if (stream) {
      stream.getVideoTracks().forEach((track) => {
        track.enabled = !isVideoEnabled;
      });
      setIsVideoEnabled(!isVideoEnabled);

      // Notify peers about video state change
      socketRef.current?.emit('media-state-change', {
        meetingId,
        userId,
        type: 'video',
        enabled: !isVideoEnabled,
      });
    }
  };

  const handleLeave = () => {
    // Stop all tracks
    if (stream) {
      stream.getTracks().forEach(track => track.stop());
    }
    if (screenStream) {
      screenStream.getTracks().forEach(track => track.stop());
    }

    // Clean up peer connections
    peersRef.current.forEach(peer => {
      if (peer.peer) {
        peer.peer.destroy();
      }
    });

    // Notify server
    socketRef.current?.emit('leave-meeting', {
      meetingId,
      userId,
    });

    // Disconnect socket
    socketRef.current?.disconnect();
    router.push('/meetings');
  };

  return (
    <div className="fixed bottom-0 left-0 right-0 bg-white border-t p-4 shadow-lg">
      <div className="max-w-4xl mx-auto flex justify-center gap-4">
        <button
          onClick={toggleAudio}
          className={`p-3 rounded-full transition-colors ${
            isAudioEnabled 
              ? 'bg-gray-600 hover:bg-gray-500' 
              : 'bg-red-500 hover:bg-red-600 text-white'
          }`}
          title={isAudioEnabled ? 'Mute' : 'Unmute'}
        >
          {isAudioEnabled ? <Mic size={24} /> : <MicOff size={24} />}
        </button>

        <button
          onClick={toggleVideo}
          className={`p-3 rounded-full transition-colors ${
            isVideoEnabled 
              ? 'bg-gray-600 hover:bg-gray-500' 
              : 'bg-red-500 hover:bg-red-600 text-white'
          }`}
          title={isVideoEnabled ? 'Stop Video' : 'Start Video'}
        >
          {isVideoEnabled ? <Video size={24} /> : <VideoOff size={24} />}
        </button>

        <button
          onClick={onScreenShare}
          className={`p-3 rounded-full transition-colors ${
            isScreenSharing 
              ? 'bg-blue-500 hover:bg-blue-600 text-white' 
              : 'bg-gray-600 hover:bg-gray-600'
          }`}
          title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
        >
          {isScreenSharing ? (
            <StopCircleIcon size={24} />
          ) : (
            <ScreenShare size={24} />
          )}
        </button>

        <button
          onClick={handleLeave}
          className="p-3 rounded-full bg-red-500 hover:bg-red-600 text-white transition-colors"
          title="Leave Meeting"
        >
          <Phone size={24} className="rotate-[135deg]" />
        </button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Render the Controls Component

Now update your app/meetings/[id]/page.tsx file to render the Control component in your return statement:

//...
import Controls from "@/components/meeting/controls";

//...

export default function ConferenceRoom() {

  //...
  return (
    <div className="flex flex-col gap-4 p-4">
      <div className="text-sm text-gray-500">
        {isConnected ? "Connected to server" : "Disconnected from server"}
      </div>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        <div className="relative">
          <video
            ref={userVideo}
            autoPlay
            muted
            playsInline
            className="w-full rounded-lg bg-gray-900"
          />
          <Controls
            stream={stream}
            screenStream={screenStream}
            isScreenSharing={isScreenSharing}
            socketRef={socketRef}
            peersRef={peersRef}
            meetingId={params.id as string}
            userId={user?.id.toString() || ""}
            onScreenShare={toggleScreenShare}
          />

          <div className="absolute bottom-2 left-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded">
            You
          </div>
        </div>
        {peers.map(({ peer, userId, stream }) => (
          <PeerVideo key={userId} peer={peer} userId={userId} stream={stream} />
        ))}
      </div>
      <Chat
        socketRef={socketRef}
        user={user as User}
        meetingId={params.id as string}
      />{" "}
    </div>
  );

  //...
}
Enter fullscreen mode Exit fullscreen mode

Render the Controls Component.png

Adding Meeting Status Management

To manage the state of users in a meeting, like knowing when a user leaves when a user joins the meeting, and seeing the list of all participants that joined the call, let's create a new store to handle the meeting state.

Create a meeting store in src/store/meeting-store.ts:

import { create } from "zustand";

interface Participant {
  id: string;
  username: string;
  isAudioEnabled: boolean;
  isVideoEnabled: boolean;
  isScreenSharing: boolean;
  isHost?: boolean;
}

interface MeetingState {
  participants: Record<string, Participant>;
  addParticipant: (participant: Participant) => void;
  removeParticipant: (id: string) => void;
  updateParticipant: (id: string, updates: Partial<Participant>) => void;
  updateMediaState: (id: string, type: 'audio' | 'video' | 'screen', enabled: boolean) => void;
  clearParticipants: () => void;
}

export const useMeetingStore = create<MeetingState>((set) => ({
  participants: {},

  addParticipant: (participant) =>
    set((state) => ({
      participants: {
        ...state.participants,
        [participant.id]: {
          ...participant,
          isAudioEnabled: true,
          isVideoEnabled: true,
          isScreenSharing: false,
          ...state.participants[participant.id],
        },
      },
    })),

  removeParticipant: (id) =>
    set((state) => {
      const { [id]: removed, ...rest } = state.participants;
      return { participants: rest };
    }),

  updateParticipant: (id, updates) =>
    set((state) => ({
      participants: {
        ...state.participants,
        [id]: {
          ...state.participants[id],
          ...updates,
        },
      },
    })),

  updateMediaState: (id, type, enabled) =>
    set((state) => ({
      participants: {
        ...state.participants,
        [id]: {
          ...state.participants[id],
          [type === 'audio' ? 'isAudioEnabled' : 
           type === 'video' ? 'isVideoEnabled' : 'isScreenSharing']: enabled,
        },
      },
    })),

  clearParticipants: () =>
    set({ participants: {} }),
}));
Enter fullscreen mode Exit fullscreen mode

Here is what the code above does:

  • Manages the state of participants in our video conference, using a Participant interface to track each user's ID, username, and media states (audio, video, and screen sharing).
  • Added the addParticipant which handles new joiners with default media states enabled.
  • The removeParticipant cleanly removes users using object destructuring.
  • The updateParticipant allows for partial updates to any participant's data.
  • The updateMediaState specifically manages toggling of audio/video/screen states.
  • The clearParticipants wipes the entire participants record clean.

Implementing Participant List

Now let's use the meeting-store to display a list of participants in a meeting. Create a participant list component in src/components/meeting/participant-list.tsx:

'use client';

import { useState } from 'react';
import { Mic, MicOff, Video, VideoOff, ScreenShare, Users, ChevronDown, ChevronUp } from 'lucide-react';
import { useMeetingStore } from '@/store/meeting-store';

export default function ParticipantList() {
  const [isExpanded, setIsExpanded] = useState(true);
  const participants = useMeetingStore((state) => state.participants);
  const participantCount = Object.keys(participants).length;
  return (
    <div className="fixed left-4 bottom-5 w-80 bg-white rounded-lg shadow-lg border z-50">
      <div 
        className="p-3 border-b flex justify-between items-center cursor-pointer"
        onClick={() => setIsExpanded(!isExpanded)}
      >
        <div className="flex items-center gap-2">
          <Users className='text-gray-600' size={20} />
          <h2 className="font-medium text-gray-600">Participants ({participantCount})</h2>
        </div>
        <button className="text-gray-500 hover:text-gray-700">
          {isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
        </button>
      </div>

      {isExpanded && (
        <div className="max-h-96 overflow-y-auto p-4 space-y-2">
          {Object.values(participants).map((participant) => (
            <div
              key={participant.id}
              className="flex items-center justify-between p-2 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
            >
              <div className="flex items-center gap-2">
                <span className="font-medium text-gray-600">{participant.username}</span>
                {participant.isHost && (
                  <span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">
                    Host
                  </span>
                )}
              </div>
              <div className="flex gap-2">
                {participant.isAudioEnabled ? (
                  <Mic size={16} className="text-green-500" />
                ) : (
                  <MicOff size={16} className="text-red-500" />
                )}
                {participant.isVideoEnabled ? (
                  <Video size={16} className="text-green-500" />
                ) : (
                  <VideoOff size={16} className="text-red-500" />
                )}
                {participant.isScreenSharing && (
                  <ScreenShare size={16} className="text-blue-500" />
                )}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then update your app/meetings/[id]/page.tsx file to render the ParticipantList component:

Implementing Participant List.png

Final Integration and Testing

We have completed our Google Meet Clone application using Next.js and Strapi. To test the application, follow the steps below:

  1. Start your Strapi backend:
cd google-meet-backend
npm run develop
Enter fullscreen mode Exit fullscreen mode
  1. Start your Next.js frontend:
cd google-meet-frontend
npm run dev
Enter fullscreen mode Exit fullscreen mode

Github Source Code

The complete source code for this tutorial is available on GitHub. Please note that the Strapi backend code resides on the main branch and the complete code is on the part_3 of the repo.

Series Wrap Up and Conclusion

In this "Building a Google Meet Clone with Strapi 5 and Next.js" blog series, we built a complete Google Meet clone with the following functionalities:

  • Real-time video conferencing using WebRTC
  • Screen sharing capabilities
  • Chat functionality
  • Participant management

Strapi 5 is now live. 👉 Start building!

Top comments (0)