DEV Community

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

Posted on

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

Ever wondered how livestream platforms broadcast real-time video to thousands of viewers?

In this two-part series, you'll learn how to build a YouTube Live clone using Next.js, TailwindCSS, and Stream. We'll build the app UI with TailwindCSS, set up authentication with Clerk, and use Prisma to model and persist the app data. Then we'll integrate the Stream Video and Audio SDK for livestreaming, and include a live chat for each stream using the Stream Chat SDK.

By the end, you'll have a functional app that mirrors YouTube Live's core features.

In this first part, we'll set up the project, add authentication, and build the home page.

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

Let’s get started!

Prerequisites

Before we begin, make sure you have these prerequisites:

  • Basic understanding of React.

  • Node.js and npm are installed on your machine.

  • Familiarity with TypeScript, Next.js, and TailwindCSS.

Setting up the Next.js Project

Let’s begin by setting up our Next.js project. We’ll use a starter template that includes all the initial setup and basic assets. To clone the template, run the following commands in your terminal:

# Clone the repository
git clone github.com/TropicolX/youtube-live-clone.git

# Navigate into the project directory
cd youtube-live-clone

# Check out the starter branch
git checkout starter

# Install the dependencies
npm install
Enter fullscreen mode Exit fullscreen mode

image.png

Setting Up the Database

To build an app like YouTube Live, we need to track which users subscribe to which channels and how they react to livestreams (such as liking or disliking). We'll set up a database for this and use Prisma to work with it.

What is Prisma?

Prisma is an open-source ORM that simplifies creating a database schema and running queries. It allows you to manage databases easily without writing SQL code directly.

Installing Prisma

Let's start by installing Prisma and its dependencies:

npm install prisma@6.19.0 --save-dev
npm install @prisma/client@6.19.0 sqlite3
Enter fullscreen mode Exit fullscreen mode

The @prisma/client library helps us interact with the database, and we’ll use SQLite for this project during development.

Next, let's initialize Prisma:

npx prisma init
Enter fullscreen mode Exit fullscreen mode

This command sets up Prisma and creates a new .env file for your database settings.

Setting Up the Database Schema

Now, let's define our database schema for the YouTube Live clone. Open the prisma/schema.prisma file and add the following:

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

generator client {
  provider = "prisma-client-js"
}

model Subscription {
  subscriberId String
  channelId    String
  createdAt    DateTime @default(now())
  @@id([subscriberId, channelId])
}

enum ReactionType {
  LIKE
  DISLIKE
}

model Reaction {
  userId       String
  livestreamId String
  type         ReactionType
  createdAt    DateTime @default(now())
  @@id([userId, livestreamId])            // one reaction per user per livestream
  @@index([livestreamId, type])           // fast counts per type
}
Enter fullscreen mode Exit fullscreen mode

This schema covers the main ways users interact in the app. Here’s what each part does:

  • Subscription: This model records which user is subscribed to which channel. The composite primary key @@id([subscriberId, channelId]) ensures that a user cannot subscribe to the same channel more than once.

  • ReactionType: This enum specifies the reactions to a livestream: LIKE or DISLIKE.

  • Reaction: This model stores user likes or dislikes for livestreams. Each user can react once per stream. The index [livestreamId, type] speeds up the calculation of reaction counts.

Configuring the Database Connection

Next, let's set up our database connection. Navigate to the .env file in your root directory and add the following:

DATABASE_URL=file:./dev.db
Enter fullscreen mode Exit fullscreen mode

This uses SQLite for local development. When you're ready for production, switch to PostgreSQL or MySQL.

Running Prisma Migrations

To create the database tables based on our schema, run the following command:

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

After running the migration, run the following command:

npx prisma generate
Enter fullscreen mode Exit fullscreen mode

This generates a Prisma client with TypeScript types that match your schema, so you can run queries with type safety.

Setting Up Prisma Client in Code

To use the Prisma client in our project, create a new prisma.ts file in the lib directory with the following code:

import { PrismaClient } from '@prisma/client';

let prisma: PrismaClient;

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  // @ts-expect-error global.prisma is used in development
  if (!global.prisma) {
    // @ts-expect-error global.prisma is used in development
    global.prisma = new PrismaClient();
  }
  // @ts-expect-error global.prisma is used in development
  prisma = global.prisma;
}

export default prisma;
Enter fullscreen mode Exit fullscreen mode

Here, we ensure only one Prisma client instance is available at a time. During development, we store it globally to prevent connection exhaustion from hot reloading.

User Authentication with Clerk

What is Clerk?

Clerk is a user management tool that helps you add authentication and user profiles to your app without building them yourself.

We’ll use Clerk in our YouTube Live clone to handle authentication.

Setting Up a Clerk Account

Clerk sign up page

To get started with Clerk, create an account on their website. Navigate to Clerk’s sign-up page and create an account using your email or a social login option.

Creating a Clerk App

image.png

Once you sign in, you’ll need to create an application for your project:

  1. Navigate to the dashboard and click "Create application".

  2. Name your application “Youtube Live Clone”.

  3. Under “Sign in options,” select “Email”, “Username”, and “Google”.

  4. Click "Create application" to complete the setup.

image.png

Once you’ve created your app, you'll be redirected to the app overview page. Here, you’ll find your Clerk API keys. Save these keys, as you’ll need them later.

image.png

Next, we need to ensure that users provide a first and last name during the sign-up process:

  1. Go to the "Configure" tab in your dashboard.

  2. Select the “User model” tab under the “User & authentication” section.

  3. Locate the "First and last name" and “Require first and last name” options and toggle them on.

  4. Click “Save” to save the changes.

Installing Clerk in Your Project

Next, let's add Clerk to your Next.js project:

  1. Install the Clerk Next.js SDK by running the command below:

    npm install @clerk/nextjs
    
  2. Create a .env.local file and add the following environment variables:

    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key
    CLERK_SECRET_KEY=your_clerk_secret_key
    

    Replace your_clerk_publishable_key and your_clerk_secret_key with the keys from your clerk app's overview page.

  3. Update your app/layout.tsx file like this:

    import type { Metadata } from 'next';
    import { ClerkProvider } from '@clerk/nextjs';
    
    import './globals.css';
    
    export const metadata: Metadata = {
      title: 'YouTube Live clone',
      description: 'A YouTube Live clone built with Next.js and Stream.',
    };
    
    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <ClerkProvider>
          <html lang="en">
            <body className="text-foreground bg-background tracking-tight antialiased">
              {children}
            </body>
          </html>
        </ClerkProvider>
      );
    }
    

    In this code, we wrap the app layout with ClerkProvider so we can use Clerk’s features throughout the app.

Creating the Sign-Up and Sign-In Pages

Next, we need to create the sign-up and sign-in pages for our app. We can do this using Clerk’s <SignUp /> and <SignIn /> components that handle all authentication logic and the UI for their respective pages.

To set up the pages in your app:

  1. Add these routes to your .env.local file:

    NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
    NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
    

    This tells the <SignUp /> and <SignIn /> components where they're mounted in your app.

  2. Create a sign-up page at app/sign-up/[[...sign-up]]/page.tsx, and add the following code:

    'use client';
    import { SignUp } from '@clerk/nextjs';
    
    export default function Page() {
      return (
        <div className="w-svw h-svh flex items-center justify-center">
          <SignUp />
        </div>
      );
    }
    
  3. Create a sign-in page at app/sign-in/[[...sign-in]]/page.tsx and add the code below:

    'use client';
    import { SignIn } from '@clerk/nextjs';
    
    export default function Page() {
      return (
        <div className="w-svw h-svh flex items-center justify-center">
          <SignIn />
        </div>
      );
    }
    
  4. Create a middleware.ts file in the src directory with the following code:

    import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
    
    const isPublicRoute = createRouteMatcher([
      '/',
      '/live/(.*)',
      '/sign-in(.*)',
      '/sign-up(.*)',
      '/api/(.*)',
    ]);
    
    export default clerkMiddleware(async (auth, request) => {
      if (!isPublicRoute(request)) {
        await auth.protect();
      }
    });
    
    export const config = {
      matcher: [
        // Skip Next.js internals and all static files, unless found in search params
        '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
        // Always run for API routes
        '/(api|trpc)(.*)',
      ],
    };
    

    In this code, we use clerkMiddleware() to let some routes stay public, while keeping the rest protected.

image.png

With these steps completed, your sign-in and sign-up pages should be fully functional.

Setting Up Stream

We'll use Stream's React SDK for Video and React Chat SDK to build the livestream and chat features in our YouTube Live clone.

Creating your Stream Account

image.png

To get started with Stream, you need to create an account:

  1. Navigate to Stream’s sign-up page and create a new account using your email or a social login.

  2. Once you sign up, you'll be requested to provide additional information. Select the "Chat Messaging" and "Video and Audio" options, as we need these tools for our app.

    Stream sign up options

  3. Finally, click "Complete Signup" to continue.

After completing the steps above, you’ll be redirected to your Stream dashboard.

Creating a New Stream App

image.png

Next, you need to create a Stream app for your project:

  1. In your Stream dashboard, click "Create App" in the top right corner.

  2. Fill in the modal form:

    • Enter a name like "youtube-live-clone" or any other name of your choice.
    • Select the region nearest to you for the best performance.
    • Leave the environment set to "Development".
  3. Click "Create App".

Once you create your app, navigate to the "App Access Keys" section and copy the keys there. You’ll need them to connect Stream to your project.

image.png

Configuring User Permissions

To allow users and guests to perform certain actions in your stream application, you need to set up the necessary permissions in your Stream dashboard.

Navigate to the “Roles & Permissions” tab under “Video & Audio” and follow these steps:

image.png

  1. Select the "user" role and choose the "livestream" scope.

  2. Click the “Edit” button and select the following permissions:

    • Update Call
    • Update Call Settings
  3. Save and confirm the changes.

This lets users create livestreams with their custom settings.

Next, we want to allow guests (users who are not signed in) to view livestreams:

image.png

  1. Select the "guest" role and choose the "livestream" scope.

  2. Click the “Edit” button and select the following permissions:

    • Join Backstage
    • Join Call
    • Join Ended Call
    • Read Call
  3. Save your changes to finish.

Installing the Stream SDKs

To begin using Stream in your Next.js project, you need to install several SDKs:

  1. Run the following command to install the necessary packages:

    npm install @stream-io/video-react-sdk stream-chat-react stream-chat
    
  2. Add your Stream API keys to your .env.local file:

    NEXT_PUBLIC_STREAM_API_KEY=your_stream_api_key
    STREAM_API_SECRET=your_stream_api_secret
    

    Replace your_stream_api_key and your_stream_api_secret with the keys from the “App Access Keys” section.

  3. Import Stream’s CSS stylesheets into your app/layout.tsx file:

    ... 
    import '@stream-io/video-react-sdk/dist/css/styles.css';
    import 'stream-chat-react/dist/css/v2/index.css';
    import './globals.css';
    ...
    

Building the Home Page

image.png

Now that Clerk and Stream are set up, let's build the home page feed. This is where users will see all the current livestreams.

Building the Header

The header will appear at the top of the home layout. It will help users navigate the app and also access their profile settings.

Go to the components directory and create a Header.tsx file with the following code:

'use client';
import Link from 'next/link';
import { SignInButton, UserButton, useUser } from '@clerk/nextjs';

import Avatar from './Avatar';
import Button from './Button';
import Bell from './icons/Bell';
import Logo from './icons/Logo';
import Plus from './icons/Plus';
import Mic from './icons/Mic';
import Search from './icons/Search';
import User from './icons/User';
import MoreVert from './icons/MoreVert';

const Header = () => {
  const { isSignedIn, user } = useUser();
  return (
    <header className="sticky top-0 z-40 w-full px-5 sm:px-10 h-14 flex items-center justify-between bg-frosted-glass backdrop-blur-[48px]">
      <Link href="/" className="flex items-center">
        <Logo />
      </Link>
      <div className="flex items-center shrink-1 basis-[732px] gap-3.5">
        <div className="hidden md:flex flex-1 h-10 ml-10">
          <input
            type="search"
            className="w-full pl-4 pr-1 ml-8 rounded-l-full bg-search-bar border border-search-bar-border border-r-0 focus:ring-[#1c62b9] focus:border-[#1c62b9] focus:outline-0"
            placeholder="Search"
          />
          <button className="min-w-16 flex items-center justify-center cursor-pointer bg-spec-button rounded-r-full border border-search-icon-border">
            <Search />
          </button>
        </div>
        <div className="hidden xl:block">
          <Button icon={Mic} />
        </div>
      </div>
      <div className="flex gap-2 items-center justify-end w-[203.5px] ml-4">
        {isSignedIn && (
          <>
            <Link href="/livestreaming">
              <Button icon={Plus}>Create</Button>
            </Link>
            <Button icon={Bell} variant="plain" />
            <div className="relative h-8 w-8 sm:ml-5">
              <div className="opacity-0">
                <UserButton />
              </div>

              <div className="absolute left-0 top-0 flex items-center justify-center pointer-events-none z-20">
                <Avatar
                  name={user?.fullName || 'U'}
                  image={user.hasImage ? user.imageUrl : undefined}
                  size={32}
                />
              </div>
            </div>
          </>
        )}
        {!isSignedIn && (
          <>
            <Button icon={MoreVert} variant="plain" />
            <SignInButton>
              <Button
                icon={User}
                className="bg-transparent hover:bg-hover-blue hover:border-hover-blue text-blue border border-hover-button"
              >
                Sign in
              </Button>
            </SignInButton>
          </>
        )}
      </div>
    </header>
  );
};

export const HeaderLoading = () => {
  return (
    <div className="sticky top-0 z-40 w-full px-10 h-14 flex items-center justify-between bg-frosted-glass backdrop-blur-[48px]">
      <div className="w-25 h-5" />
      <div className="flex items-center shrink-1 basis-[732px] gap-3.5">
        <div className="flex flex-1 h-10 ml-10">
          <input
            type="search"
            className="w-full pl-4 pr-1 ml-8 rounded-l-full bg-search-bar border border-search-bar-border border-r-0 focus:ring-[#1c62b9] focus:border-[#1c62b9] focus:outline-0"
            placeholder="Search"
          />
          <button className="min-w-16 flex items-center justify-center cursor-pointer bg-spec-button rounded-r-full border border-search-icon-border" />
        </div>
        <div className="h-9 w-9 rounded-full bg-skeleton animate-pulse" />
      </div>
      <div className="flex gap-4 items-center justify-end w-[203.5px]">
        <div className="h-9 w-9 rounded-full bg-skeleton animate-pulse" />
        <div className="h-9 w-9 rounded-full bg-skeleton animate-pulse" />
        <div className="h-9 w-9 rounded-full bg-skeleton animate-pulse" />
      </div>
    </div>
  );
};

export default Header;
Enter fullscreen mode Exit fullscreen mode

Here, we create the <Header /> component along with its skeleton UI. We also use the following Clerk modules:

  • useUser: This hook provides access to the user’s current data. We extract the isSignedIn and user states from the hook to check if the user is signed in and to retrieve their information, respectively.

  • <UserButton/>: This component renders an avatar that opens a dropdown menu with options to manage their account settings. Since we want to display our custom <Avatar /> while still using the dropdown menu, we set the <UserButton />'s opacity to 0.

  • <SignInButton />: This component wraps our custom button and serves as a link to the sign-in page.

Building the Home Layout

Next, let’s build out our home layout. This layout wraps all pages that need access to Stream’s livestream and chat features.

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

'use client';
import { ReactNode, useEffect, useState } from 'react';
import { OwnUserResponse, StreamChat } from 'stream-chat';
import { Chat } from 'stream-chat-react';
import { useUser } from '@clerk/nextjs';
import {
  StreamVideo,
  StreamVideoClient,
  User,
} from '@stream-io/video-react-sdk';
import { nanoid } from 'nanoid';

import { HeaderLoading } from '@/components/Header';
import Header from '@/components/Header';

interface HomeLayoutProps {
  children: ReactNode;
}

export const GUEST_ID = `guest_${nanoid(15)}`;

const tokenProvider = async (userId: string) => {
  const response = await fetch('/api/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ userId: userId || GUEST_ID }),
  });
  const data = await response.json();
  return data.token;
};

const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY as string;
const GUEST_USER: User = { id: GUEST_ID, type: 'guest', name: 'Guest' };

const HomeLayout = ({ children }: HomeLayoutProps) => {
  const { user, isLoaded, isSignedIn } = useUser();
  const [loading, setLoading] = useState(true);
  const [chatClient, setChatClient] = useState<StreamChat>();
  const [videoClient, setVideoClient] = useState<StreamVideoClient>();

  useEffect(() => {
    const setUpStream = async () => {
      try {
        const chatClient = StreamChat.getInstance(API_KEY);
        let streamUser: User | OwnUserResponse;
        if (isSignedIn) {
          const clerkUser = user!;
          streamUser = {
            id: clerkUser.id,
            name: clerkUser.fullName!,
            image: clerkUser.hasImage ? clerkUser.imageUrl : undefined,
            custom: {
              username: clerkUser.username!,
            },
          };
        } else {
          streamUser = GUEST_USER;
        }

        const customProvider = async () => {
          const token = await tokenProvider(streamUser.id);
          return token;
        };

        if (!chatClient.user) {
          if (isSignedIn)
            await chatClient.connectUser(streamUser, customProvider);
          else await chatClient.connectAnonymousUser();
        }

        setChatClient(chatClient);
        const videoClient = StreamVideoClient.getOrCreateInstance({
          apiKey: API_KEY,
          user: streamUser,
          tokenProvider: customProvider,
        });
        setVideoClient(videoClient);
      } catch (error) {
        console.error('Error setting up Stream:', error);
      } finally {
        setLoading(false);
      }
    };

    if (isLoaded) setUpStream();

    return () => {
      if (!isSignedIn) {
        chatClient?.disconnectUser();
        videoClient?.disconnectUser();
      }
    };
  }, [user, videoClient, chatClient, isSignedIn, isLoaded]);

  if (loading || !isLoaded) return <HeaderLoading />;

  return (
    <Chat client={chatClient!} theme="str-chat__theme-dark">
      <StreamVideo client={videoClient!}>
        <Header />
        {children}
      </StreamVideo>
    </Chat>
  );
};

export default HomeLayout;
Enter fullscreen mode Exit fullscreen mode

There’s a lot going on here, so let’s break it down:

  • We create a tokenProvider function that fetches a user token from a /api/token endpoint. This token is required to log in the user to Stream.

  • We also generate a GUEST_ID and GUEST_USER for signed-out users so they can use a guest account for Stream.

  • We define a setupStream function that creates the chatClient and videoClient instances.

  • We ensure the user is disconnected from the clients when the component is unmounted.

  • While the client is connecting, we render the <HeadingLoading /> component.

  • Once connected, we wrap the <Header /> and children in Stream’s <Chat /> and <StreamVideo /> components. These components take in their respective client instances and provide the data to all other components in the layout.

Creating the Token API Route

Next, let’s create the route for the /api/token endpoint we referenced earlier.

Create a /api/token directory in the app folder, then add a route.ts file with the following:

import { StreamChat } from 'stream-chat';

const API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
const SECRET = process.env.STREAM_API_SECRET!;

export async function POST(request: Request) {
  const client = StreamChat.getInstance(API_KEY, SECRET);

  const body = await request.json();

  const userId = body?.userId;

  if (!userId) {
    return Response.error();
  }

  const token = client.createToken(userId);

  const response = {
    userId: userId,
    token: token,
  };

  return Response.json(response);
}
Enter fullscreen mode Exit fullscreen mode

Here, we create and return an auth token for a user based on the provided userId.

Sign up page

Creating the Stream Card Component

The StreamCard component represents a single livestream tile in the app feed.

In the components directory, create a StreamCard.tsx file with the following code:

/* eslint-disable @next/next/no-img-element */
import Link from 'next/link';

import Avatar from './Avatar';
import LiveBadge from './LiveBadge';

interface StreamCardProps {
  channelImage: string;
  channelName: string;
  thumbnail: string;
  title: string;
  views: number;
  id: string;
}

const channelImageSize = 36;

const StreamCard = ({
  id,
  channelImage,
  channelName,
  thumbnail,
  title,
  views,
}: StreamCardProps) => {
  const href = id.length === 1 ? '#' : `/live/${id}`;
  return (
    <div className="flex flex-col w-(--card-width) mx-2 cursor-pointer">
      <Link href={href} className="contents">
        <div className="relative aspect-video rounded-xl overflow-hidden">
          <img
            src={thumbnail}
            alt={title}
            className="w-full h-full object-cover"
          />
          <div className="absolute bottom-2.5 right-2.5 ml-1">
            <LiveBadge />
          </div>
        </div>
        <div className="flex mt-3 gap-3">
          <div className="shrink-0">
            <Avatar
              size={channelImageSize}
              name={channelName}
              image={channelImage}
            />
          </div>
          <div className="flex flex-col pr-6">
            <h3 className="leading-5 line-clamp-2 font-bold">{title}</h3>
            <p className="text-sm text-gray font-medium truncate">
              {channelName}
            </p>
            <p className="text-sm text-gray font-medium truncate">
              {Intl.NumberFormat('en', { notation: 'compact' }).format(views)}{' '}
              watching
            </p>
          </div>
        </div>
      </Link>
    </div>
  );
};

export default StreamCard;
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We define a conditional href that points to a placeholder (#) for mock data, or to the livestream page (/live/${id}) otherwise.

  • We render the StreamCard UI, including the channel name, channel avatar, livestream thumbnail, and view count.

Creating the Livestreams Container

Next, let’s create the Livestreams component. This component is responsible for fetching, organizing, and rendering the list of livestreams, including a featured stream at the top of the feed.

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

import { useRouter } from 'next/navigation';
import { Call, StreamCall } from '@stream-io/video-react-sdk';

import Button from './Button';
import StreamCard from './StreamCard';
import LivePlayer from './LivePlayer';
import Avatar from './Avatar';
import LiveBadge from './LiveBadge';
import { mockStreams } from '@/lib/mockData';

interface LivestreamsProps {
  feed: Call[];
}

const Livestreams = ({ feed }: LivestreamsProps) => {
  const router = useRouter();
  const firstLivestream = feed[0];
  const homeFeed = firstLivestream
    ? [...feed.slice(1), ...mockStreams]
    : mockStreams;

  const goToLivestream = async () => {
    await firstLivestream.leave();
    router.push(`/live/${firstLivestream.id}`);
  };

  return (
    <div className="flex flex-col w-full gap-8">
      {firstLivestream && (
        <div
          onClick={goToLivestream}
          className="relative w-full h-[490px] rounded-2xl overflow-hidden bg-black cursor-pointer"
        >
          <div className="absolute top-0 right-0 left-auto pl-10 ml-6 w-[871px] max-w-[unset] h-full bg-linear-(--promo-gradient) z-20" />
          <div className="absolute top-0 left-0 pl-10 ml-6 w-[871px] max-w-[unset] h-full bg-linear-(--promo-gradient) z-20 flex flex-col justify-center items-start gap-2">
            <h1 className="text-4xl font-bold">
              {firstLivestream.state.custom.name}
            </h1>
            <div className="flex items-center gap-3">
              <Avatar
                size={24}
                name={firstLivestream.state.createdBy?.name || 'Streamer'}
                image={firstLivestream.state.createdBy?.image || undefined}
              />
              <span className="text-sm text-gray font-bold">
                {firstLivestream.state.createdBy?.name || 'Streamer'}
              </span>
              <LiveBadge />
            </div>
            <span className="text-sm text-gray">
              {firstLivestream.state.session?.participants.length} watching
            </span>
            <Button
              variant="plain"
              className="bg-foreground text-background w-fit mt-2"
            >
              Watch Live
            </Button>
          </div>
          <div className="absolute top-0 right-0 left-auto w-[871px] max-w-[unset] h-full flex items-center justify-center bg-black">
            <StreamCall call={firstLivestream}>
              <LivePlayer />
            </StreamCall>
          </div>
        </div>
      )}
      <div className="flex flex-col gap-4">
        <div className="w-full flex items-center justify-between ml-2">
          <h2 className="text-2xl font-extrabold">Live Now</h2>
          <Button className="bg-transparent hover:bg-[#263850] hover:border-[#263850] text-[#3ea6ff] border border-transparent">
            View all
          </Button>
        </div>
        <div className="flex flex-wrap [&>div:nth-child(n+4)]:mt-6 gap-4 sm:gap-0 items-end lg:items-start">
          {homeFeed.map((stream) => (
            <StreamCard
              key={stream.id}
              id={stream.id}
              title={stream.state.custom.name}
              channelName={stream.state.createdBy?.name as string}
              channelImage={stream.state.createdBy?.image as string}
              thumbnail={stream.state.thumbnails?.image_url as string}
              views={stream.state.participantCount || 0}
            />
          ))}
        </div>
      </div>
    </div>
  );
};

export default Livestreams;
Enter fullscreen mode Exit fullscreen mode

Here’s what’s going on in the code above:

  • The component uses feed[0] to get the firstLivestream (the featured stream) and removes it from the array when constructing the main homeFeed for the grid.

  • The homeFeed array is populated with the remaining live streams and mockStreams data.

  • The goToLivestream function ensures the featured call is left (await firstLivestream.leave()) before routing to the dedicated stream page.

  • We conditionally render the large featured player section using firstLivestream && (...).

  • A LivePlayer component is rendered within a StreamCall context for the featured stream, allowing it to display the video feed.

  • The component maps over the homeFeed array to render the grid of StreamCard components.

Building the useHostParticipant Hook

The useHostParticipant hook is a custom utility that identifies the primary broadcaster (the host) in the current live stream.

Create a hooks directory in the src folder, create a file named useHostParticipant.tsx and add the following code:

import {
  hasAudio,
  hasScreenShare,
  hasVideo,
  publishingVideo,
  useCallStateHooks,
  type StreamVideoParticipant,
} from '@stream-io/video-react-sdk';

export interface ParticipantTrackFlags {
  hasVideo: boolean;
  hasAudio: boolean;
  hasScreenShare: boolean;
}

export function useHostParticipant():
  | (StreamVideoParticipant & ParticipantTrackFlags)
  | null {
  const { useParticipants } = useCallStateHooks();
  const participants = useParticipants({ sortBy: publishingVideo });
  const host = participants.find((p) => p.roles.includes('host'));

  if (!host) {
    return null;
  }

  return {
    ...host,
    hasVideo: hasVideo(host),
    hasAudio: hasAudio(host),
    hasScreenShare: hasScreenShare(host),
  };
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We define the ParticipantTrackFlags interface to extend the base participant object with track status properties (hasVideo, hasAudio, hasScreenShare).

  • We use useCallStateHooks to get the useParticipants hook, which retrieves all participants and sorts them by publishingVideo.

  • The host is identified by searching for a participant whose roles array explicitly includes 'host'.

  • The hook returns the host object, spreading its original properties and adding the track flags determined by Stream SDK helper functions.

Implementing the Live Player Component

The LivePlayer component renders the main video feed and includes an interactive, custom control bar that appears when the user hovers over the player area. It uses the useHostParticipant hook to ensure it displays the host's primary feed. We also referenced it earlier in our Livestreams component.

Create a LivePlayer.tsx file in the components directory and add the following code:

import {
  ParticipantView,
  type StreamVideoParticipant,
} from '@stream-io/video-react-sdk';
import { useState } from 'react';
import clsx from 'clsx';

import Button from './Button';
import Pause from './icons/Pause';
import SkipNext from './icons/SkipNext';
import VolumeUp from './icons/VolumeUp';
import Subtitles from './icons/Subtitles';
import Settings from './icons/Settings';
import PIP from './icons/PIP';
import Crop from './icons/Crop';
import Fullscreen from './icons/Fullscreen';
import {
  ParticipantTrackFlags,
  useHostParticipant,
} from '@/hooks/useHostParticipant';

export default function LivePlayer() {
  const host = useHostParticipant();
  const [isHovered, setIsHovered] = useState(false);

  return (
    <div
      className="w-full h-full"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      {host ? (
        <>
          <div className="absolute top-0 left-0 w-full h-full object-cover">
            <ParticipantView
              participant={host}
              trackType={
                host.hasScreenShare ? 'screenShareTrack' : 'videoTrack'
              }
              ParticipantViewUI={null}
              VideoPlaceholder={null}
            />
          </div>
          <Overlay participant={host} />
        </>
      ) : (
        <div className="w-full h-full flex items-center justify-center bg-black">
          <span className="text-white">No active livestream</span>
        </div>
      )}

      <div
        className={clsx(
          'absolute bg-linear-(--bottom-fade-gradient) w-full h-full',
          isHovered
            ? 'opacity-100 transition-opacity duration-200'
            : 'opacity-0 transition-opacity duration-200'
        )}
      />
      <div
        className={clsx(
          'absolute bottom-0 left-0 px-2 pb-1 w-full flex flex-col items-start justify-between gap-1',
          isHovered
            ? 'opacity-100 transition-opacity duration-200'
            : 'opacity-0 transition-opacity duration-200'
        )}
      >
        <div className="relative bg-red-500 w-full h-[3px] rounded-md">
          <div className="absolute right-0 -top-0.5 w-2 h-2 rounded-full bg-red-500" />
        </div>
        <div className="flex items-center justify-between w-full">
          <div className="flex items-center gap-1">
            <Button icon={Pause} variant="plain" size="sm" />
            <Button icon={SkipNext} variant="plain" size="sm" />
            <Button icon={VolumeUp} variant="plain" size="sm" />
            <div className="flex items-center gap-2">
              <div className="w-2 h-2 rounded-full bg-red-500" />
              <span className="text-sm text-white font-medium">Live</span>
            </div>
          </div>
          <div className="flex items-center gap-1">
            <Button icon={Subtitles} variant="plain" size="sm" />
            <Button icon={Settings} variant="plain" size="sm" />
            <Button icon={PIP} variant="plain" size="sm" />
            <Button icon={Crop} variant="plain" size="sm" />
            <Button icon={Fullscreen} variant="plain" size="sm" />
          </div>
        </div>
      </div>
    </div>
  );
}

function Overlay(props: {
  participant: StreamVideoParticipant & ParticipantTrackFlags;
}) {
  const hasNoTracks =
    !props.participant.hasVideo &&
    !props.participant.hasAudio &&
    !props.participant.hasScreenShare;

  if (hasNoTracks)
    return (
      <>
        <div className="absolute top-0 left-0 w-full h-full">
          {/* eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element */}
          <img
            src={props.participant.image}
            className="w-full h-full object-contain"
          />
        </div>
        <div className="absolute left-0 bottom-0 w-full p-4 bg-black/20 backdrop-blur-sm text-left">
          <div>
            <span>{`Live stream offline`}</span>
          </div>
        </div>
      </>
    );
  return null;
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • The component uses useState to track isHovered and attaches onMouseEnter and onMouseLeave handlers to control the state.

  • The mainstream view uses ParticipantView to display the host's video and prioritize the screenShareTrack if available.

  • The control bar's visibility is managed with the clsx utility, which applies opacity-100 or opacity-0 conditionally based on the isHovered state with a smooth transition.

  • The inner Overlay component checks the host's media flags (from useHostParticipant) and displays the host's image and an "Live stream offline" message if no video, audio, or screen share tracks are active.

  • The control bar renders multiple Button components with icons for common video controls (e.g., Pause, Volume, Fullscreen).

Putting it All Together

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

Update the page.tsx file inside the (home) directory with the following code:

'use client';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { Call, useStreamVideoClient } from '@stream-io/video-react-sdk';

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

const liveImageUrl =
  'https://yt3.googleusercontent.com/JBRD2aGs76cb1XRqw9hmHcof1OqDHLCq4PAi4jPXb6Xli6Fgk71yAwptfAZuyksVXo822FRE=s72-c-c0x00ffffff-no-rwa';

export default function Home() {
  const [feed, setFeed] = useState<Call[]>([]);
  const [loading, setLoading] = useState(true);
  const videoClient = useStreamVideoClient()!;

  useEffect(() => {
    const fetchLivestreams = async () => {
      try {
        const result = await videoClient.queryCalls({
          filter_conditions: {
            type: { $eq: 'livestream' },
            backstage: { $eq: false },
            ongoing: { $eq: true },
          },
        });
        const firstLivestream = result.calls[0];
        if (firstLivestream) {
          await joinCall(firstLivestream);
          setFeed([firstLivestream, ...result.calls.slice(1)]);
          return;
        }

        setFeed([]);
      } catch (error) {
        console.error('Error fetching livestreams:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchLivestreams();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

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

  return (
    <div>
      <main className="px-12 pb-10 flex flex-col gap-[32px] row-start-2 items-start">
        <div className="flex items-center gap-4">
          <Image src={liveImageUrl} width={72} height={72} alt="Live" />
          <h1 className="text-4xl font-extrabold">Live</h1>
        </div>
        <Livestreams feed={feed} />
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We use the useStreamVideoClient hook to access the video client necessary for data interaction.

  • The useEffect hook runs once on mount to fetch the live stream data using videoClient.queryCalls.

  • The query uses filter_conditions to retrieve only calls where type is 'livestream', backstage is false, and the stream is ongoing.

  • If a stream is found, we call the joinCall utility function to join the first stream (the featured stream) with muted devices, then update the feed state.

  • The component displays a Spinner component while the loading state is true.

Next, let’s add the utility module we imported earlier. Create a utils.ts file in the lib directory with the following code:

import { Call } from '@stream-io/video-react-sdk';

export const joinCall = async (call: Call) => {
  const defaultDeviceAction = 'disable';
  const callSetupPromises = [
    call.microphone.disableSpeakingWhileMutedNotification(),
    call.camera[defaultDeviceAction](),
    call.microphone[defaultDeviceAction](),
  ];

  Promise.all(callSetupPromises);
  await call.join();
};
Enter fullscreen mode Exit fullscreen mode

Here, we add a joinCall function that does the following:

  • Takes a Stream call as an argument.

  • Sets the defaultDeviceAction to 'disable'.

  • Creates and invokes an array of promises to disable the camera, microphone, and the "speaking while muted" notification.

  • Joins the call using call.join().

image.png

And with that, we've created our livestream feed!

Conclusion

So far, we’ve set up our YouTube Live clone from the ground up:

  • Configured Prisma for our database

  • Added authentication with Clerk

  • Integrated Stream and built the home page

With all the foundation in place, we’re ready to move on to the fun stuff.

In Part Two, we’ll build the pages where users can create and watch livestreams, and add live chat.

Stay tuned!

Top comments (0)