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

Published: (December 12, 2025 at 01:00 PM EST)
5 min read
Source: Dev.to

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 useEffect fetches the initial reaction counts and the current user’s reaction when the component mounts.
  • The send function updates local state instantly (optimistic UI) before the API request finishes.
  • If the request fails, the catch block 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.)

Back to Blog

Related posts

Read more »