DEV Community

Cover image for Build a YouTube Live Clone with Next.js, Clerk, and TailwindCSS - Part Two
Oluwabusayo Jacobs
Oluwabusayo Jacobs

Posted on

Build a YouTube Live Clone with Next.js, Clerk, and TailwindCSS - Part Two

In part one of this two-part series, we focused on building the foundation of our YouTube Live clone by:

  • Configuring Prisma for our database
  • Adding authentication with Clerk
  • Integrating Stream into the app
  • Building the home page

In this second part, we’ll build the pages for creating and watching livestreams with the Stream React Video SDK. We’ll also add a live chat functionality to the livestreams with the React Chat SDK.

You can view the live demo and access the complete source code on GitHub.

Building the Reactions System (Likes/Dislikes)

To get started, we’ll implement a reaction system similar to YouTube that lets viewers engage with livestreams. This requires a custom hook to manage the frontend state and an API route to handle database persistence via Prisma.

Building the useReactions Hook

The useReactions hook manages the fetch logic and handles optimistic UI updates. This ensures the interface responds instantly when a user clicks a button, updating the numbers immediately while the server processes the request in the background.

Create a file named useReactions.tsx in your hooks directory with the following code:

'use client';
import { useEffect, useState } from 'react';

type MyReaction = 'LIKE' | 'DISLIKE' | null;

export function useReactions(livestreamId: string) {
  const [likes, setLikes] = useState(0);
  const [dislikes, setDislikes] = useState(0);
  const [myReaction, setMyReaction] = useState<MyReaction>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let alive = true;
    (async () => {
      const res = await fetch(`/api/livestreams/${livestreamId}/reactions`, {
        cache: 'no-store',
      });
      const data = await res.json();
      if (!alive) return;
      setLikes(data.likes);
      setDislikes(data.dislikes);
      setMyReaction(data.myReaction);
      setLoading(false);
    })();
    return () => {
      alive = false;
    };
  }, [livestreamId]);

  const send = async (type: 'LIKE' | 'DISLIKE') => {
    // optimistic update
    const prev = { likes, dislikes, myReaction };
    if (type === 'LIKE') {
      if (myReaction === 'LIKE') {
        setLikes(likes - 1);
        setMyReaction(null);
      } else if (myReaction === 'DISLIKE') {
        setDislikes(dislikes - 1);
        setLikes(likes + 1);
        setMyReaction('LIKE');
      } else {
        setLikes(likes + 1);
        setMyReaction('LIKE');
      }
    } else {
      if (myReaction === 'DISLIKE') {
        setDislikes(dislikes - 1);
        setMyReaction(null);
      } else if (myReaction === 'LIKE') {
        setLikes(likes - 1);
        setDislikes(dislikes + 1);
        setMyReaction('DISLIKE');
      } else {
        setDislikes(dislikes + 1);
        setMyReaction('DISLIKE');
      }
    }

    try {
      const res = await fetch(`/api/livestreams/${livestreamId}/reactions`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ type }),
      });
      if (!res.ok) throw new Error('Failed');
      const data = await res.json();
      setLikes(data.likes);
      setDislikes(data.dislikes);
      setMyReaction(data.myReaction);
    } catch {
      // revert on error
      setLikes(prev.likes);
      setDislikes(prev.dislikes);
      setMyReaction(prev.myReaction);
    }
  };

  return {
    likes,
    dislikes,
    myReaction,
    like: () => send('LIKE'),
    dislike: () => send('DISLIKE'),
    loading,
  };
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • The useEffect fetches the initial reaction counts and the user's current status when the component mounts.

  • The send function immediately updates local state (likes, dislikes) based on the user's action (e.g., removing a like, switching from dislike to like) before the API request completes.

  • If the API request fails, the catch block reverts the state to the prev values to ensure the UI remains consistent with the database.

Creating the Reactions API Route

Next, let’s create the backend endpoint to handle these requests.

Create a file at app/api/livestreams/[livestreamId]/reactions/route.ts with the following code:

import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';

import prisma from '@/lib/prisma';

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ livestreamId: string }> }
) {
  const { userId } = await auth();
  const { livestreamId } = await params;

  const [likes, dislikes, mine] = await Promise.all([
    prisma.reaction.count({ where: { livestreamId, type: 'LIKE' } }),
    prisma.reaction.count({ where: { livestreamId, type: 'DISLIKE' } }),
    userId
      ? prisma.reaction.findUnique({
          where: { userId_livestreamId: { userId, livestreamId } },
          select: { type: true },
        })
      : null,
  ]);

  return NextResponse.json({
    likes,
    dislikes,
    myReaction: mine?.type ?? null,
  });
}

export async function POST(
  req: Request,
  { params }: { params: Promise<{ livestreamId: string }> }
) {
  const { userId } = await auth();
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { livestreamId } = await params;
  const { type } = (await req.json()) as { type: 'LIKE' | 'DISLIKE' };

  if (type !== 'LIKE' && type !== 'DISLIKE') {
    return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
  }

  // If user clicks the same reaction again, remove it. If different, upsert.
  const existing = await prisma.reaction.findUnique({
    where: { userId_livestreamId: { userId, livestreamId } },
  });

  if (existing && existing.type === type) {
    await prisma.reaction.delete({
      where: { userId_livestreamId: { userId, livestreamId } },
    });
  } else {
    await prisma.reaction.upsert({
      where: { userId_livestreamId: { userId, livestreamId } },
      create: { userId, livestreamId, type },
      update: { type },
    });
  }

  // Return fresh counts and state
  const [likes, dislikes, mine] = await Promise.all([
    prisma.reaction.count({ where: { livestreamId, type: 'LIKE' } }),
    prisma.reaction.count({ where: { livestreamId, type: 'DISLIKE' } }),
    prisma.reaction.findUnique({
      where: { userId_livestreamId: { userId, livestreamId } },
      select: { type: true },
    }),
  ]);

  return NextResponse.json({
    likes,
    dislikes,
    myReaction: mine?.type ?? null,
  });
}
Enter fullscreen mode Exit fullscreen mode

Here, we create a function for GET requests to fetch reactions and for POST requests to create or update a reaction.

Building the Livestream Studio

The livestream studio is the command center for the broadcaster. This is where users create a stream, retrieve their OBS stream keys, and manage the broadcast.

Creating the Livestream Layout

We’ll start with the layout, which initializes the stream session. It ensures that when a user visits the studio, a Call exists, and the chat channel is ready.

Create a livestreaming/layout.tsx file in the (home) directory and add the following code:

'use client';
import { ReactNode, useEffect, useState } from 'react';
import {
  Call,
  StreamCall,
  useStreamVideoClient,
} from '@stream-io/video-react-sdk';
import { nanoid } from 'nanoid';
import { useUser } from '@clerk/nextjs';
import { useChatContext } from 'stream-chat-react';

import Spinner from '@/components/Spinner';
import { joinCall } from '@/lib/utils';

interface LivestreamLayoutProps {
  children: ReactNode;
}

const LivestreamLayout = ({ children }: LivestreamLayoutProps) => {
  const [livestream, setLivestream] = useState<Call>();
  const [isLoading, setIsLoading] = useState(true);

  const { user, isLoaded } = useUser();
  const videoClient = useStreamVideoClient()!;
  const { client: chatClient, setActiveChannel } = useChatContext();

  useEffect(() => {
    const setupLivestream = async () => {
      try {
        const { calls } = await videoClient.queryCalls({
          filter_conditions: {
            type: { $eq: 'livestream' },
            created_by_user_id: { $eq: user?.id },
            ongoing: { $eq: true },
          },
          limit: 1,
        });

        let call: Call;

        if (calls.length > 0) {
          call = calls[0];
          setLivestream(call);
        } else {
          const callId = nanoid();
          call = videoClient.call('livestream', callId);

          await call.create({
            data: {
              members: [{ user_id: user?.id as string, role: 'host' }],
              custom: { name: `${user?.fullName} Live Stream` },
              settings_override: {
                backstage: {
                  enabled: true,
                },
              },
            },
          });

          setLivestream(call);
        }

        await joinCall(call);

        const channel = chatClient.channel('livestream', call.id);
        if (channel.cid) {
          await channel.watch();
        } else {
          await channel.create();
        }

        setActiveChannel(channel);
      } catch (error) {
        console.error('Error creating or fetching call:', error);
      } finally {
        setIsLoading(false);
      }
    };

    if (isLoaded) {
      setupLivestream();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isLoaded]);

  if (isLoading) {
    return (
      <div className="flex flex-1 h-[calc(100svh-56px)] items-center justify-center">
        <Spinner />
      </div>
    );
  }

  return <StreamCall call={livestream}>{children}</StreamCall>;
};

export default LivestreamLayout;
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • The useEffect queries for an existing active stream to prevent duplicate sessions. If none is found, it creates a new one.

  • We call the joinCall(call) function to connect to the session.

  • We explicitly enable backstage: { enabled: true }. This puts the stream in a "waiting room" state, allowing the host to configure their software (like OBS) before officially going live to the public.

  • The component joins the call and sets up the Stream Chat channel, then wraps the children in <StreamCall> to provide the necessary SDK context to the page.

Building the Livestream Studio Page

The livestream studio page lets the host view their preview, retrieve their RTMP stream keys, and end the broadcast.

Navigate to the livestreaming directory and create a page.tsx file with the following:

'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
  useCall,
  useCallStateHooks,
  useStreamVideoClient,
} from '@stream-io/video-react-sdk';
import clsx from 'clsx';

import Button from '@/components/Button';
import Public from '@/components/icons/Public';
import TextField from '@/components/TextField';
import Spinner from '@/components/Spinner';
import LivePlayer from '@/components/LivePlayer';
import { useHostParticipant } from '@/hooks/useHostParticipant';
import { useReactions } from '@/hooks/useReactions';

const LiveStreaming = () => {
  const videoClient = useStreamVideoClient()!;
  const livestream = useCall()!;
  const router = useRouter();

  const {
    useCallEndedAt,
    useIsCallLive,
    useCallCustomData,
    useCallIngress,
    useCallSession,
  } = useCallStateHooks();
  const { likes } = useReactions(livestream.id);
  const endedAt = useCallEndedAt();
  const isLive = useIsCallLive();
  const ingress = useCallIngress();
  const customData = useCallCustomData();
  const host = useHostParticipant();
  const session = useCallSession();

  const streamUrl = ingress?.rtmp?.address ?? '';
  const streamKey = videoClient.streamClient.tokenManager.getToken() ?? '';
  const livestreamTitle = customData?.name ?? '';
  const participants = session?.participants ?? [];

  const copyStreamUrl = () => {
    navigator.clipboard.writeText(streamUrl).catch((err) => {
      console.error('Could not copy text: ', err);
    });
  };

  const copyStreamKey = () => {
    navigator.clipboard.writeText(streamKey).catch((err) => {
      console.error('Could not copy text: ', err);
    });
  };

  useEffect(() => {
    const goLive = async () => {
      await livestream?.goLive();
    };

    if (host?.hasVideo) {
      goLive();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [host?.hasVideo]);

  const endLivestream = async () => {
    try {
      await livestream?.endCall();
      router.push('/');
    } catch (error) {
      console.error('Error ending livestream:', error);
    }
  };

  return (
    <div className="flex flex-1 flex-col lg:flex-row basis-[0.000000001px] h-[calc(100svh-56px)]">
      {/* Main Section */}
      <div className="relative flex flex-1 basis-[0.000000001px]">
        <div className="w-full flex flex-col flex-1 basis-[0.000000001px] p-6 min-[860px]:min-w-[860px] gap-4">
          {/* Summary Panel */}
          <div className="flex flex-col bg-brand-bg border border-outline-color rounded-xl overflow-hidden xl:h-[290px]">
            {/* Top section */}
            <div className="flex flex-col lg:flex-row grow">
              <div className="relative flex items-center justify-center bg-[#0D0D0D] min-[480px]:min-w-[424px] h-[216px] lg:h-full overflow-hidden">
                {!isLive && (
                  <div className="flex flex-col items-center gap-4 text-center text-sm">
                    <Spinner color="white" />
                    <span>Connect streaming software to go live</span>
                    <span>
                      Viewers will be able to find your stream once you go live
                    </span>
                  </div>
                )}
                {!endedAt && isLive && <LivePlayer />}
              </div>
              <div className="flex flex-1 px-6 pt-6 pb-6 xl:pb-0 xl:max-h-[216px] gap-10">
                <div className="flex flex-1 flex-col gap-3">
                  <div className="overflow-hidden align-top">
                    <div className="text-xs text-gray tracking-[.011em]">
                      Title
                    </div>
                    <div className="text-lg truncate">{livestreamTitle}</div>
                  </div>
                  <div className="overflow-hidden align-top">
                    <div className="text-xs text-gray tracking-[.011em]">
                      Category
                    </div>
                    <div className="text-base truncate">{'People & Blogs'}</div>
                  </div>
                  <div className="overflow-hidden align-top">
                    <div className="text-xs text-gray tracking-[.011em]">
                      Privacy
                    </div>
                    <div className="inline-flex items-center text-base truncate">
                      <div className="pr-1.5">
                        <Public />
                      </div>
                      {'Public'}
                    </div>
                  </div>
                  <div className="flex items-center gap-6">
                    <div className="overflow-hidden align-top">
                      <div className="text-xs text-gray tracking-[.011em]">
                        Viewers waiting
                      </div>
                      <div className="text-base truncate">
                        {participants.length}
                      </div>
                    </div>
                    <div className="overflow-hidden align-top">
                      <div className="text-xs text-gray tracking-[.011em]">
                        Likes
                      </div>
                      <div className="text-base truncate">{likes}</div>
                    </div>
                  </div>
                </div>
                <div className="mt-1.5 pr-2.5">
                  <Button>Edit</Button>
                </div>
              </div>
            </div>
            {/* Bottom section */}
            <div className="px-6 h-12 flex items-center shrink border-t border-t-outline-color gap-6">
              <div
                className={clsx(
                  'w-3 h-3 rounded-full',
                  isLive ? 'bg-green-400' : 'bg-[#B7B7B7]'
                )}
              />
              <div className="max-w-3/4 truncate tracking-normal">
                {!isLive &&
                  'Start sending us your video from your streaming software to go live'}
                {!endedAt && isLive && 'Streaming now'}
              </div>
            </div>
          </div>
          {/* Livestream Controls */}
          <div className="flex flex-1 flex-col min-h-[342px] basis-[0.000000001px] bg-brand-bg border border-outline-color rounded-xl overflow-hidden">
            <div className="w-full flex flex-col flex-1 basis-[0.000000001px]">
              <div className="h-12 flex items-center px-6 border-b border-b-outline-color">
                <div className="relative inline-flex items-center justify-center h-full align-middle ml-2 mr-8 cursor-pointer">
                  <span className="font-medium truncate">Stream settings</span>
                  <div className="absolute bottom-0 w-full h-0.5 bg-foreground" />
                </div>
                <div className="relative inline-flex items-center justify-center h-full align-middle ml-2 mr-8 cursor-pointer">
                  <span className="text-gray font-medium truncate">
                    Analytics
                  </span>
                </div>
                <div className="relative inline-flex items-center justify-center h-full align-middle ml-2 mr-8 cursor-pointer">
                  <span className="text-gray font-medium truncate">
                    Stream health
                  </span>
                </div>
              </div>
              <div className="flex flex-col gap-4 p-6">
                <div className="text-gray font-medium tracking-normal">
                  Stream key
                </div>
                <div className="flex items-center gap-4">
                  <TextField
                    label="Stream key (paste in encoder)"
                    variant="secondary"
                    value={streamKey}
                    readOnly
                  />
                  <Button
                    variant="plain"
                    className="border border-spec-button hover:bg-hover-button"
                    onClick={copyStreamKey}
                  >
                    Copy
                  </Button>
                </div>
                <div className="flex items-center gap-4">
                  <TextField
                    label="Stream URL"
                    variant="secondary"
                    value={streamUrl}
                    readOnly
                  />
                  <Button
                    variant="plain"
                    className="border border-spec-button hover:bg-hover-button"
                    onClick={copyStreamUrl}
                  >
                    Copy
                  </Button>
                </div>
                <div className="w-fit mt-4">
                  <Button onClick={endLivestream}>End livestream</Button>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      {/* Chat */}
      <div className="my-0 lg:my-6 lg:mr-6">
      </div>
    </div>
  );
};

export default LiveStreaming;
Enter fullscreen mode Exit fullscreen mode

A lot is going on here, so let’s break it down:

  • We utilize multiple Stream SDK hooks (useCallEndedAt, useIsCallLive, useCallIngress, etc.) to get real-time information about the stream's status and data. It also uses the custom useReactions hook to display the number of likes.

  • The useEffect watches the host?.hasVideo status. When video input is detected from the host's streaming software (like OBS), it automatically calls livestream?.goLive() to transition the session from backstage to live.

  • We retrieve the streamUrl (RTMP address) from useCallIngress and the secure streamKey from the videoClient.streamClient.tokenManager.getToken().

  • We provide dedicated Copy buttons for the stream URL and key, and an End livestream button which calls livestream?.endCall() and redirects the user to the home page.

  • We add a circle indicator that uses the isLive state to dynamically change color (green for live, grey for inactive).

Livestreaming

Building the Live Chat

Creating the LiveChat Component

The LiveChat component is the primary container for the chat UI. It uses the chat context (provided by the LivestreamLayout) to render a messaging feed for the livestream.

Create a LiveChat.tsx in the components folder and add the following code:

import {
  Channel,
  MessageInput,
  MessageList,
  useChatContext,
  Window,
} from "stream-chat-react";
import { useUser } from "@clerk/nextjs";

import Button from "./Button";
import ChevronDown from "./icons/ChevronDown";
import MoreVert from "./icons/MoreVert";

const LiveChat = () => {
  const { isSignedIn } = useUser();
  const { channel } = useChatContext();

  const NullComponent = () => null;

  return (
    <div id="channel" className="h-full">
      <Channel
        channel={channel}
        AttachmentSelector={NullComponent}
        DateSeparator={NullComponent}
      >
        <div className="flex flex-1 basis-[0.000000001px] px-6 h-[unset] lg:px-0 xl:min-w-[400px] sm:max-w-[400px] shrink-0">
          <div className="flex flex-col flex-1 border border-outline-color rounded-xl overflow-hidden">
            <div className="h-12 p-2 flex items-center justify-between border-b border-b-outline-color">
              <div className="flex items-center gap-1 mx-1 cursor-pointer">
                <span className="text-foreground font-medium ml-3">
                  Top chat
                </span>
                <ChevronDown />
              </div>
              <Button icon={MoreVert} variant="plain"></Button>
            </div>
            <div className="flex grow h-[600px] xl:h-auto py-2">
              <Window>
                <MessageList />
              </Window>
            </div>
            {isSignedIn && (
              <div className="py-2.5 px-6 border-t border-t-outline-color max-h-[104px]">
                <MessageInput
                  additionalTextareaProps={{
                    placeholder: "Chat...",
                  }}
                />
              </div>
            )}
          </div>
        </div>
      </Channel>
    </div>
  );
};

export default LiveChat;
Enter fullscreen mode Exit fullscreen mode

Here we implement several essential features:

  • The component is wrapped in the Stream Chat Channel component, which provides the context for all child components.

  • We pass NullComponent to props like AttachmentSelector and DateSeparator to disable those default UI elements.

  • We specify our own CustomMessage component to be rendered for each entry in the MessageList.

  • The MessageList is rendered inside a Window component to handle the virtualized scrolling of messages.

  • The MessageInput component is only rendered if the user is signed in (isSignedIn from Clerk), preventing anonymous users from chatting.

Next, we need to place this component into our livestream dashboard. Update your livestreaming/page.tsx file to include the LiveChat:

...
import LiveChat from '@/components/LiveChat';
...

const LiveStreaming = () => {
  ...

  return (
    <div className="flex flex-1 flex-col lg:flex-row basis-[0.000000001px] h-[calc(100svh-56px)]">
      {/* Main Section */}
      ...
      {/* Chat */}
      <div className="my-0 lg:my-6 lg:mr-6">
        <LiveChat />
      </div>
    </div>
  );
};

export default LiveStreaming;
Enter fullscreen mode Exit fullscreen mode

Livestreaming

Customizing the Chat Appearance

At this stage, the chat works, but it uses the default Stream SDK styling, which conflicts with our dark theme. To fix this, we need to override specific CSS classes to make the backgrounds transparent and align the input box with our design.

Add the following overrides to your globals.css file:

...

#channel .str-chat__channel {
  background: transparent;
  width: 100%;
}

#channel .str-chat__channel .str-chat__container,
#channel .str-chat__main-panel-inner {
  display: contents;
}

#channel .str-chat__channel .str-chat__container .str-chat__main-panel {
  display: contents;
  min-width: auto;
}

#channel .str-chat__list,
#channel .str-chat__empty-channel,
#channel .str-chat__message-input {
  background: transparent;
}

#channel .str-chat__message-input {
  padding: 0;
}

#channel .str-chat__message-textarea-container {
  background: var(--color-spec-button);
  font-size: 14px;
  border: none;
}

#channel .rta__textarea.str-chat__textarea__textarea.str-chat__message-textarea::placeholder {
  color: var(--color-foreground);
  font-size: 14px;
}

#channel .rta__textarea.str-chat__textarea__textarea.str-chat__message-textarea {
  color: var(--color-foreground);
  font-size: 14px;
}

#channel .str-chat__list {
  display: flex;
  flex-direction: column;
  justify-content: end;
}

#channel .str-chat__li:hover {
  background: var(--color-spec-button);
}

.str-video__participant-view {
  margin: 0 auto;
}

@media (max-width: 768px) {
  :root {
    --card-width: calc(100% / 2 - var(--grid-item-margin) - .01px);
    --grid-items-per-row: 2;
  }
}

@media (max-width: 560px) {
  :root {
    --card-width: calc(100% - var(--grid-item-margin) - .01px);
    --grid-items-per-row: 1;
  }
}
Enter fullscreen mode Exit fullscreen mode

Page

Creating the CustomMessage Component

With the layout styled, we need to customize the individual messages. This component will allow us to visually distinguish between regular users and the streamer (host).

In the components directory, create a CustomMessage.tsx file:

import { useChatContext, useMessageContext } from 'stream-chat-react';
import clsx from 'clsx';

import Avatar from './Avatar';

const CustomMessage = () => {
  const { channel } = useChatContext();
  const { message } = useMessageContext();

  const isStreamer = channel?.data?.created_by?.id === message.user?.id;

  return (
    <div
      data-message-id={message.id}
      className="flex items-start gap-4 text-sm px-2.5 py-1 cursor-pointer"
    >
      <div className="w-6 h-6 shrink-0">
        <Avatar
          size={24}
          name={message?.user?.name}
          image={message?.user?.image}
        />
      </div>
      <div className="pt-[1px]">
        <div
          className={clsx(
            'inline-flex h-5 rounded-xs mr-2 px-1 py-0.5',
            isStreamer ? 'bg-amber-300' : 'bg-transparent'
          )}
        >
          <span
            className={clsx(
              'text-xs font-bold',
              isStreamer ? 'text-black' : 'text-gray'
            )}
          >
            {message?.user?.name}
          </span>
        </div>
        <span className="text-[13px] text-foreground break-words overflow-hidden">
          {message.text}
        </span>
      </div>
    </div>
  );
};

export default CustomMessage;
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We use the useMessageContext hook to access the specific message data.

  • We calculate the isStreamer boolean by comparing the message author's ID with the channel creator's ID.

  • We use clsx to conditionally apply an amber background (bg-amber-300) and black text if the message comes from the streamer.

Next, we need to add the component to our live chat. Update the LiveChat to include the CustomMessage component:

...
import CustomMessage from "./CustomMessage";
...

const LiveChat = () => {
  ...

  return (
    <div id="channel" className="h-full">
      <Channel
        ...
      >
        <div className="flex flex-1 basis-[0.000000001px] px-6 h-[unset] lg:px-0 xl:min-w-[400px] sm:max-w-[400px] shrink-0">
          <div className="flex flex-col flex-1 border border-outline-color rounded-xl overflow-hidden">
            ...
            <div className="flex grow h-[600px] xl:h-auto py-2">
              <Window>
                <MessageList Message={CustomMessage} />
              </Window>
            </div>
            ...
          </div>
        </div>
      </Channel>
    </div>
  );
};

export default LiveChat;
Enter fullscreen mode Exit fullscreen mode

Live chat

Creating the CustomSendButton Component

To ensure our chat input matches the rest of the application's design, we replace the default Stream SDK send button with our own button component.

In the components directory, create a CustomSendButton.tsx file:

import { SendButtonProps } from 'stream-chat-react';

import Button from './Button';
import Send from './icons/Send';

const CustomSendButton = ({ sendMessage, ...other }: SendButtonProps) => {
  return (
    <div className="ml-2.5 [&>button]:h-9 [&>button]:w-9">
      {/* @ts-expect-error send message */}
      <Button icon={Send} variant="plain" onClick={sendMessage} {...other} />
    </div>
  );
};

export default CustomSendButton;
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • The component accepts the sendMessage function from the Stream Chat SDK props.

  • It renders our custom Button component using the Send icon.

  • The onClick handler is wired directly to sendMessage, preserving the SDK's core functionality while giving us complete control over the styling.

Next, let’s add the button to the LiveChat component:

...
import CustomSendButton from "./CustomSendButton";
...

const LiveChat = () => {
  ...

  return (
    <div id="channel" className="h-full">
      <Channel
        ...
        SendButton={CustomSendButton}
      >
        ...
      </Channel>
    </div>
  );
};

export default LiveChat;
Enter fullscreen mode Exit fullscreen mode

And with that, the livestream studio is complete! The host can now start a stream, manage the broadcast, and chat with viewers.

Livestream studio

Building the LiveStream Page

Now let's create the viewer page that allows users to subscribe to channels and engage with livestreams.

Creating the Subscription API Routes

To manage channel subscriptions, we need two API routes: one to handle actions (subscribe/unsubscribe/count) and another to check the current user's status.

Create a file at app/api/subscriptions/[channelId]/route.ts with the following snippet:

import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

import prisma from '@/lib/prisma';

export async function POST(
  _: Request,
  { params }: { params: Promise<{ channelId: string }> }
) {
  const { userId } = await auth();
  const { channelId } = await params;
  if (!userId)
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  await prisma.subscription.upsert({
    where: {
      subscriberId_channelId: {
        subscriberId: userId,
        channelId,
      },
    },
    update: {},
    create: { subscriberId: userId, channelId },
  });

  return NextResponse.json({ ok: true });
}

export async function DELETE(
  _: Request,
  { params }: { params: Promise<{ channelId: string }> }
) {
  const { userId } = await auth();
  const { channelId } = await params;
  if (!userId)
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  await prisma.subscription.delete({
    where: {
      subscriberId_channelId: {
        subscriberId: userId,
        channelId,
      },
    },
  });

  return NextResponse.json({ ok: true });
}

export async function GET(
  _: Request,
  { params }: { params: Promise<{ channelId: string }> }
) {
  const { channelId } = await params;
  const count = await prisma.subscription.count({
    where: { channelId },
  });

  return NextResponse.json({ count });
}
Enter fullscreen mode Exit fullscreen mode

This API exposes three key actions:

  • POST (Subscribe): We use upsert to handle the subscription. If the subscription already exists, it does nothing; if not, it creates it. This prevents errors if a user clicks "Subscribe" multiple times.

  • DELETE (Unsubscribe): We remove the record matching the composite key of subscriberId (the user) and channelId.

  • GET (Count): We return the total count of subscriptions for the channel, which is publicly available information.

Next, we need a route that allows the frontend to quickly check if the currently logged-in user is subscribed to the channel they are viewing.

Create a file at app/api/subscriptions/[channelId]/status/route.ts with the following code:

import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';

import prisma from '@/lib/prisma';

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ channelId: string }> }
) {
  const { userId } = await auth();
  const { channelId } = await params;

  // If not signed in, treat as not subscribed
  if (!userId) {
    return NextResponse.json({ subscribed: false });
  }

  const sub = await prisma.subscription.findUnique({
    where: {
      subscriberId_channelId: {
        subscriberId: userId, // Clerk user id
        channelId, // Channel owner Clerk id
      },
    },
    select: { channelId: true },
  });

  return NextResponse.json({ subscribed: Boolean(sub) });
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We use auth() from Clerk to identify the user securely.

  • If the user is not authenticated, we immediately return { subscribed: false }.

  • We query the Subscription table for a record matching the current user and the channel.

  • We return a simple boolean indicating whether that record exists.

Putting it All Together

Let’s put all the components together to create our viewer page.

Create a file at /(home)/live/[livestreamId]/page.tsx and add the following code:

'use client';
import { use, useEffect, useState } from 'react';
import {
  Call,
  StreamCall,
  useCallStateHooks,
  useStreamVideoClient,
} from '@stream-io/video-react-sdk';
import { useChatContext } from 'stream-chat-react';
import clsx from 'clsx';

import Avatar from '@/components/Avatar';
import Button from '@/components/Button';
import Chat from '@/components/icons/Chat';
import Clip from '@/components/icons/Clip';
import Download from '@/components/icons/Download';
import Dislike from '@/components/icons/Dislike';
import DislikeFilled from '@/components/icons/DislikeFilled';
import Like from '@/components/icons/Like';
import LikeFilled from '@/components/icons/LikeFilled';
import LiveChat from '@/components/LiveChat';
import LivePlayer from '@/components/LivePlayer';
import More from '@/components/icons/More';
import Share from '@/components/icons/Share';
import Spinner from '@/components/Spinner';
import { getLivestreamStartedAt, joinCall } from '@/lib/utils';
import { useReactions } from '@/hooks/useReactions';

interface ViewLivestreamProps {
  params: Promise<{
    livestreamId: string;
  }>;
}

const ViewLivestream = ({ params }: ViewLivestreamProps) => {
  const { livestreamId } = use(params);
  const { client: chatClient, setActiveChannel } = useChatContext();
  const videoClient = useStreamVideoClient()!;
  const [loading, setLoading] = useState(true);
  const [livestream, setLivestream] = useState<Call>();
  const [subCount, setSubCount] = useState(0);
  const [isUserSubscribed, setIsUserSubscribed] = useState(false);
  const {
    likes,
    myReaction,
    like,
    dislike,
    loading: reacting,
  } = useReactions(livestreamId);

  const livestreamTitle = livestream?.state.custom.name ?? '';
  const livestreamOwner = livestream?.state.createdBy;
  const streamStartedAt = getLivestreamStartedAt(livestream?.state.startedAt);

  useEffect(() => {
    const loadLivestream = async () => {
      try {
        const livestream = videoClient.call('livestream', livestreamId);
        await joinCall(livestream);

        const channel = chatClient.channel('livestream', livestream.id);
        if (channel.cid) {
          await channel.watch();
        } else {
          await channel.create();
        }

        setLivestream(livestream);
        setActiveChannel(channel);

        await getSubscriptionCount(livestream.state.createdBy?.id as string);
        await getUserSubscriptionStatus(
          livestream.state.createdBy?.id as string
        );
      } catch (error) {
        console.error('Error joining livestream:', error);
      } finally {
        setLoading(false);
      }
    };

    const getSubscriptionCount = async (channelId: string) => {
      try {
        const res = await fetch(`/api/subscriptions/${channelId}`);
        const data = await res.json();

        setSubCount(data.count);
      } catch (error) {
        console.error('Error fetching subscription count:', error);
      }
    };

    const getUserSubscriptionStatus = async (channelId: string) => {
      try {
        const res = await fetch(`/api/subscriptions/${channelId}/status`);
        const data = await res.json();

        setIsUserSubscribed(data.subscribed);
      } catch (error) {
        console.error('Error fetching user subscription status:', error);
      }
    };

    loadLivestream();

    return () => {
      livestream?.leave();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const subscribeToChannel = async () => {
    try {
      const channelId = livestream?.state.createdBy?.id;
      const res = await fetch(`/api/subscriptions/${channelId}`, {
        method: isUserSubscribed ? 'DELETE' : 'POST',
      });
      const data = await res.json();
      if (data.ok) {
        setIsUserSubscribed(!isUserSubscribed);
        setSubCount((prev) => prev + (isUserSubscribed ? -1 : 1));
      }
    } catch (error) {
      console.error('Error subscribing to channel:', error);
    }
  };

  if (loading) {
    return (
      <div className="flex flex-1 h-[calc(100svh-56px)] justify-center items-center">
        <Spinner />
      </div>
    );
  }

  return (
    <StreamCall call={livestream}>
      <div className="relative flex flex-col items-center gap-8 pr-3 lg:flex-row lg:items-start lg:gap-0 lg:pb-0 flex-1 h-full justify-between">
        <div className="flex flex-col w-full items-center">
          <div className="relative w-full h-[56.25vw] max-h-[calc(100vh-169px)] min-h-[480px] bg-black overflow-x-clip">
            <LivePlayer />
          </div>
          <div className="flex flex-1 flex-col px-6 mt-3 mb-6 min-w-full max-w-[1225px]">
            <h1 className="text-xl font-bold truncate">{livestreamTitle}</h1>
            <div className="flex flex-col md:flex-row items-start gap-4 md:gap-0 mt-3">
              <div className="flex w-fit max-w-full mr-8 flex-initial items-center">
                <div className="flex gap-3">
                  <Avatar
                    size={40}
                    fontSize={20}
                    name={livestreamOwner?.name}
                    image={livestreamOwner?.image}
                  />
                  <div className="flex flex-col mr-6">
                    <p className="font-semibold leading-4">
                      {livestreamOwner?.name}
                    </p>
                    <p className="text-gray text-sm font-semibold">
                      {subCount} subscriber{subCount === 1 ? '' : 's'}
                    </p>
                  </div>
                </div>
                {livestreamOwner?.id !== chatClient.userID && (
                  <Button
                    className="bg-foreground text-background hover:bg-foreground/50"
                    onClick={subscribeToChannel}
                  >
                    {isUserSubscribed ? 'Unsubscribe' : 'Subscribe'}
                  </Button>
                )}
              </div>
              <div className="flex flex-grow flex-shrink items-center justify-end gap-2">
                <div className="flex items-center bg-spec-button rounded-full pr-3 pl-4 gap-1 [&>button]:w-9 [&>button]:h-9">
                  <Button
                    icon={myReaction === 'LIKE' ? LikeFilled : Like}
                    onClick={like}
                    disabled={reacting}
                    className="relative rounded-r-none"
                    variant="plain"
                  >
                    <div className="-ml-9">{likes > 0 ? likes : ''}</div>
                  </Button>
                  <div
                    className={clsx('h-6 w-[1px] bg-gray', likes > 0 && 'ml-2')}
                  />
                  <Button
                    icon={myReaction === 'DISLIKE' ? DislikeFilled : Dislike}
                    onClick={dislike}
                    disabled={reacting}
                    className="relative rounded-l-none"
                    variant="plain"
                  />
                </div>
                <Button icon={Share}>Share</Button>
                <div className="hidden md:contents">
                  <Button icon={Download}>Download</Button>
                </div>
                <Button icon={Clip}>Clip</Button>
                <div className="[&>button]:w-9 [&>button]:h-9">
                  <Button icon={More} />
                </div>
              </div>
            </div>
            <div className="flex flex-col md:flex-row mt-3 gap-3">
              <div className="flex flex-col w-full bg-spec-button p-3 rounded-xl gap-1">
                <div className="inline-flex gap-2">
                  <Watching />
                  <span className="text-sm font-bold">
                    Started streaming {streamStartedAt?.toLocaleString()}
                  </span>
                </div>
                <div className="truncate text-sm text-gray font-medium italic">
                  No description has been added to this video.
                </div>
                <span className="text-sm font-medium">...more</span>
              </div>
              <div className="flex flex-col w-full bg-spec-button p-3 rounded-xl gap-3 cursor-pointer">
                <h2 className="text-sm font-semibold">Live chat replay</h2>
                <div className="flex items-center gap-3">
                  <Button icon={Chat} variant="plain" className="w-6 h-6" />
                  <span className="text-sm font-semibold grow">
                    See what others said about this video while it was live.
                  </span>
                  <div className="shrink-0">
                    <Button size="sm">Open panel</Button>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
        <div className="sticky top-14 h-[calc(100vh-58px)] pb-8 lg:pb-0 w-full md:w-auto">
          <LiveChat />
        </div>
      </div>
    </StreamCall>
  );
};

const Watching = () => {
  const { useCallSession } = useCallStateHooks();
  const session = useCallSession();
  const participants = session?.participants || [];
  return (
    <span className="text-sm font-bold">
      {participants.length} watching now
    </span>
  );
};

export default ViewLivestream;
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • When the component mounts, the useEffect hook triggers a parallel fetch. It joins the video call using joinCall, connects to the LiveChat channel, and hits our API endpoints to get the current subscriber count and status.

  • The subscribeToChannel function manages the subscription state. It toggles between POST and DELETE requests based on the current status and performs an optimistic update, changing the UI instantly before the server confirms the action to make the app feel faster.

  • We utilize the useReactions hook to power the Like and Dislike buttons. The buttons are temporarily disabled (disabled={reacting}) while an API request is in flight to prevent spamming.

  • The entire UI is wrapped in <StreamCall>. This is crucial because child components like LivePlayer and Watching rely on the context to access the active call state.

  • The UI adapts to the user's state: we show an "Unsubscribe" button if they are already following the channel, and display filled icons if they have already liked or disliked the stream.

  • Finally, we use a helper utility, getLivestreamStartedAt, to parse the raw timestamp provided by the Stream backend. This ensures we can safely display the start time or calculate elapsed time without cluttering the component's logic.

Now that we are using getLivestreamStartedAt, let’s implement the helper function in lib/utils.ts:

// ...

export const getLivestreamStartedAt = (startedAtInit: Date | undefined) => {
  if (!startedAtInit) return '';
  const startedAt = new Date(startedAtInit);
  const now = new Date();
  const diff = now.getTime() - startedAt.getTime();
  const diffHours = Math.floor(diff / (1000 * 60 * 60));
  const diffMinutes = Math.floor(diff / (1000 * 60));

  if (diffMinutes < 1) {
    return 'just now';
  }
  if (diffMinutes < 60) {
    return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
  }
  if (diffHours < 24) {
    return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
  }
  const date = startedAt.toLocaleDateString(undefined, {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
  });

  return `on ${date}`;
};
Enter fullscreen mode Exit fullscreen mode

Livestream viewer page

And that’s it! You’ve successfully built a fully functional YouTube Live clone from the ground up.

Conclusion

With this final step, we've successfully built a YouTube Live clone with livestreaming and chat features using Stream Video and Chat SDKs.

We've covered a lot in this two-part series, from laying the foundation with Prisma, Clerk, and the home page to building the interactive livestream studio and the viewer page.

I encourage you to explore Stream SDKs further and extend your app even more. You could add features such as recording, moderation tools, custom emojis, and more.

Feel free to view the live demo and GitHub repository to see everything in action and explore the code.

Top comments (0)