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
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
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
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
}
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:LIKEorDISLIKE.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
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
After running the migration, run the following command:
npx prisma generate
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;
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
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
Once you sign in, you’ll need to create an application for your project:
Navigate to the dashboard and click "Create application".
Name your application “Youtube Live Clone”.
Under “Sign in options,” select “Email”, “Username”, and “Google”.
Click "Create application" to complete the setup.
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.
Next, we need to ensure that users provide a first and last name during the sign-up process:
Go to the "Configure" tab in your dashboard.
Select the “User model” tab under the “User & authentication” section.
Locate the "First and last name" and “Require first and last name” options and toggle them on.
Click “Save” to save the changes.
Installing Clerk in Your Project
Next, let's add Clerk to your Next.js project:
-
Install the Clerk Next.js SDK by running the command below:
npm install @clerk/nextjs -
Create a
.env.localfile and add the following environment variables:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key CLERK_SECRET_KEY=your_clerk_secret_keyReplace
your_clerk_publishable_keyandyour_clerk_secret_keywith the keys from your clerk app's overview page. -
Update your
app/layout.tsxfile 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
ClerkProviderso 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:
-
Add these routes to your
.env.localfile:
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-upThis tells the
<SignUp />and<SignIn />components where they're mounted in your app. -
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> ); } -
Create a sign-in page at
app/sign-in/[[...sign-in]]/page.tsxand 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> ); } -
Create a
middleware.tsfile in thesrcdirectory 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.
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
To get started with Stream, you need to create an account:
Navigate to Stream’s sign-up page and create a new account using your email or a social login.
-
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.
Finally, click "Complete Signup" to continue.
After completing the steps above, you’ll be redirected to your Stream dashboard.
Creating a New Stream App
Next, you need to create a Stream app for your project:
In your Stream dashboard, click "Create App" in the top right corner.
-
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".
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.
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:
Select the "user" role and choose the "livestream" scope.
-
Click the “Edit” button and select the following permissions:
- Update Call
- Update Call Settings
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:
Select the "guest" role and choose the "livestream" scope.
-
Click the “Edit” button and select the following permissions:
- Join Backstage
- Join Call
- Join Ended Call
- Read Call
Save your changes to finish.
Installing the Stream SDKs
To begin using Stream in your Next.js project, you need to install several SDKs:
-
Run the following command to install the necessary packages:
npm install @stream-io/video-react-sdk stream-chat-react stream-chat -
Add your Stream API keys to your
.env.localfile:
NEXT_PUBLIC_STREAM_API_KEY=your_stream_api_key STREAM_API_SECRET=your_stream_api_secretReplace
your_stream_api_keyandyour_stream_api_secretwith the keys from the “App Access Keys” section. -
Import Stream’s CSS stylesheets into your
app/layout.tsxfile:
... 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
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;
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 theisSignedInanduserstates 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 to0.<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;
There’s a lot going on here, so let’s break it down:
We create a
tokenProviderfunction that fetches a user token from a/api/tokenendpoint. This token is required to log in the user to Stream.We also generate a
GUEST_IDandGUEST_USERfor signed-out users so they can use a guest account for Stream.We define a
setupStreamfunction that creates thechatClientandvideoClientinstances.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 />andchildrenin 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);
}
Here, we create and return an auth token for a user based on the provided userId.
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;
In the code above:
We define a conditional
hrefthat points to a placeholder (#) for mock data, or to the livestream page (/live/${id}) otherwise.We render the
StreamCardUI, 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;
Here’s what’s going on in the code above:
The component uses
feed[0]to get thefirstLivestream(the featured stream) and removes it from the array when constructing the mainhomeFeedfor the grid.The
homeFeedarray is populated with the remaining live streams andmockStreamsdata.The
goToLivestreamfunction 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
LivePlayercomponent is rendered within aStreamCallcontext for the featured stream, allowing it to display the video feed.The component maps over the
homeFeedarray to render the grid ofStreamCardcomponents.
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),
};
}
In the code above:
We define the
ParticipantTrackFlagsinterface to extend the base participant object with track status properties (hasVideo,hasAudio,hasScreenShare).We use
useCallStateHooksto get theuseParticipantshook, which retrieves all participants and sorts them bypublishingVideo.The host is identified by searching for a participant whose
rolesarray 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;
}
In the code above:
The component uses
useStateto trackisHoveredand attachesonMouseEnterandonMouseLeavehandlers to control the state.The mainstream view uses
ParticipantViewto display the host's video and prioritize thescreenShareTrackif available.The control bar's visibility is managed with the
clsxutility, which appliesopacity-100oropacity-0conditionally based on theisHoveredstate with a smooth transition.The inner
Overlaycomponent checks the host's media flags (fromuseHostParticipant) 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
Buttoncomponents 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>
);
}
In the code above:
We use the
useStreamVideoClienthook to access the video client necessary for data interaction.The
useEffecthook runs once on mount to fetch the live stream data usingvideoClient.queryCalls.The query uses
filter_conditionsto retrieve only calls wheretypeis'livestream',backstageisfalse, and thestreamisongoing.If a stream is found, we call the
joinCallutility function to join the first stream (the featured stream) with muted devices, then update thefeedstate.The component displays a
Spinnercomponent while theloadingstate istrue.
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();
};
Here, we add a joinCall function that does the following:
Takes a Stream
callas an argument.Sets the
defaultDeviceActionto'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().
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)