Build a YouTube Live Clone with Next.js, Clerk, and TailwindCSS - Part Two
Source: Dev.to
Overview
In part one of this two‑part series we set up the foundation of our YouTube Live clone:
- Configured Prisma for the database
- Added authentication with Clerk
- Integrated Stream into the app
- Built the home page
In this second part we’ll create the pages for creating and watching livestreams using the Stream React Video SDK. We’ll also add live‑chat functionality with the React Chat SDK.
You can view the live demo and the complete source code on GitHub.
Building the Reactions System (Likes/Dislikes)
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 persist data via Prisma.
Building the useReactions Hook
The useReactions hook fetches the initial counts, handles optimistic UI updates, and syncs with the backend.
Create hooks/useReactions.tsx:
'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,
};
}
Key points
- The
useEffectfetches the initial reaction counts and the current user’s reaction when the component mounts. - The
sendfunction updates local state instantly (optimistic UI) before the API request finishes. - If the request fails, the
catchblock rolls back to the previous state, keeping the UI consistent with the database.
Creating the Reactions API Route
Create app/api/livestreams/[livestreamId]/reactions/route.ts:
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<any> }
) {
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<any> }
) {
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 });
}
// Toggle or upsert reaction
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 current reaction
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,
});
}
The route provides:
- GET – returns total likes, dislikes, and the authenticated user’s current reaction (or
null). - POST – toggles or updates a reaction and returns the refreshed counts.
Building the Livestream Studio
The livestream studio is the broadcaster’s command center: creating a stream, retrieving OBS keys, and managing the broadcast.
Creating the Livestream Layout
The layout initializes the Stream call and ensures the chat channel is ready when a user visits the studio.
Create app/(home)/livestreaming/layout.tsx:
'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) => {
// implementation details...
return (
<>
{/* layout markup */}
{children}
</>
);
};
export default LivestreamLayout;
(The full implementation continues in the repository; the snippet above shows the file’s structure and imports.)