LiveKit is an open-source WebRTC platform for real-time video and audio — <LiveKitRoom url={wsUrl} token={participantToken}> connects to a room. useParticipants() returns all participants. useTracks([Track.Source.Camera]) subscribes to video tracks. <VideoTrack trackRef={track}> renders a participant’s video. useLocalParticipant() returns localParticipant with setCameraEnabled(true) and setMicrophoneEnabled(true). Access tokens are generated server-side with new AccessToken(apiKey, apiSecret) and token.addGrant({ room, roomJoin: true, canPublish: true }). RoomEvent.TrackSubscribed and RoomEvent.ParticipantConnected handle lifecycle events. Screen sharing uses participant.setScreenShareEnabled(true). Data channel messaging sends arbitrary bytes between participants. useConnectionState() tracks connection status. LiveKit has a hosted cloud offering (LiveKit Cloud) and a self-hosted Docker image. Claude Code generates LiveKit token servers, video grid components, audio-only rooms, and screen sharing patterns.
CLAUDE.md for LiveKit
## LiveKit Stack
- Version: livekit-server-sdk >= 2.5 (server), @livekit/components-react >= 2.5 (client)
- Token: new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity, name }).addGrant({ room, roomJoin: true, canPublish: true })
- Room: <LiveKitRoom url={process.env.NEXT_PUBLIC_LIVEKIT_URL} token={token} connectOptions={{ autoSubscribe: true }}>
- Participants: const participants = useParticipants()
- Tracks: const tracks = useTracks([Track.Source.Camera, Track.Source.ScreenShare])
- Local: const { localParticipant } = useLocalParticipant(); localParticipant.setCameraEnabled(true)
- Events: room.on(RoomEvent.TrackSubscribed, (track, pub, participant) => ...)
- Data: localParticipant.publishData(bytes, { reliable: true })
Token Server
// app/api/livekit/token/route.ts — generate room access tokens
import { NextRequest, NextResponse } from "next/server"
import { AccessToken, RoomServiceClient } from "livekit-server-sdk"
import { auth } from "@clerk/nextjs/server"
import { db } from "@/lib/db"
import { z } from "zod"
const TokenRequestSchema = z.object({
roomName: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/),
participantName: z.string().min(1).max(50).optional(),
})
export async function POST(request: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = TokenRequestSchema.safeParse(await request.json())
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 })
const { roomName, participantName } = body.data
const user = await db.user.findUniqueOrThrow({ where: { clerkId: userId } })
// Create access token — server-side only
const token = new AccessToken(
process.env.LIVEKIT_API_KEY!,
process.env.LIVEKIT_API_SECRET!,
{
identity: userId,
name: participantName ?? user.name,
ttl: "4h", // Token valid for 4 hours
},
)
token.addGrant({
room: roomName,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
// Screen share — only for paid tiers
canPublishSources: ["camera", "microphone", ...(user.plan === "pro" ? ["screen_share"] : [])],
})
return NextResponse.json({
token: await token.toJwt(),
livekitUrl: process.env.NEXT_PUBLIC_LIVEKIT_URL,
})
}
// Create a room via LiveKit REST API
export async function createRoom(roomName: string) {
const roomService = new RoomServiceClient(
process.env.LIVEKIT_URL!,
process.env.LIVEKIT_API_KEY!,
process.env.LIVEKIT_API_SECRET!,
)
return roomService.createRoom({
name: roomName,
emptyTimeout: 300, // Delete room after 5 minutes empty
maxParticipants: 50,
metadata: JSON.stringify({ createdAt: Date.now() }),
})
}
Video Conference Component
// components/video/VideoConference.tsx — multi-participant video room
"use client"
import { useEffect, useState } from "react"
import {
LiveKitRoom,
VideoConference as LiveKitVideoConference,
GridLayout,
ParticipantTile,
RoomAudioRenderer,
ControlBar,
useTracks,
useLocalParticipant,
useParticipants,
useConnectionState,
} from "@livekit/components-react"
import "@livekit/components-styles"
import { Track, ConnectionState } from "livekit-client"
interface VideoRoomProps {
roomName: string
onLeave: () => void
}
export function VideoRoom({ roomName, onLeave }: VideoRoomProps) {
const [token, setToken] = useState<string | null>(null)
const [livekitUrl, setLivekitUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch("/api/livekit/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomName }),
})
.then(r => r.json())
.then(data => {
if (data.error) throw new Error(data.error)
setToken(data.token)
setLivekitUrl(data.livekitUrl)
})
.catch(err => setError(err.message))
}, [roomName])
if (error) return <div className="text-red-500 p-4">{error}</div>
if (!token || !livekitUrl) return <div className="p-4">Connecting...</div>
return (
<LiveKitRoom
video={true}
audio={true}
token={token}
serverUrl={livekitUrl}
connectOptions={{ autoSubscribe: true }}
onDisconnected={onLeave}
style={{ height: "100vh" }}
>
<CustomVideoGrid />
<RoomAudioRenderer />
<ControlBar
controls={{
camera: true,
microphone: true,
screenShare: true,
leave: true,
chat: false,
}}
/>
</LiveKitRoom>
)
}
// Custom grid layout
function CustomVideoGrid() {
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ onlySubscribed: false },
)
const participants = useParticipants()
const { localParticipant } = useLocalParticipant()
const connectionState = useConnectionState()
if (connectionState === ConnectionState.Connecting) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="size-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-sm text-muted-foreground">Joining room...</p>
</div>
</div>
)
}
return (
<div className="flex-1 overflow-hidden">
{/* Participant count */}
<div className="absolute top-4 left-4 z-10 bg-black/50 text-white text-sm px-3 py-1 rounded-full">
{participants.length} participant{participants.length !== 1 ? "s" : ""}
</div>
{tracks.length <= 4 ? (
// Small rooms — equal grid
<div className={`h-full grid gap-2 p-2 ${
tracks.length === 1 ? "grid-cols-1" :
tracks.length === 2 ? "grid-cols-2" :
"grid-cols-2"
}`}>
{tracks.map(track => (
<ParticipantTile
key={track.participant.identity + track.source}
trackRef={track}
className="rounded-xl overflow-hidden"
/>
))}
</div>
) : (
// Large rooms — LiveKit's built-in grid
<GridLayout tracks={tracks} style={{ height: "100%" }}>
<ParticipantTile />
</GridLayout>
)}
</div>
)
}
Audio-Only Room
// components/voice/VoiceRoom.tsx — audio-only podcast/call room
"use client"
import { useParticipants, useLocalParticipant, AudioTrack, useTracks } from "@livekit/components-react"
import { Track } from "livekit-client"
export function VoiceParticipantList() {
const participants = useParticipants()
const { localParticipant, isMicrophoneEnabled } = useLocalParticipant()
const tracks = useTracks([Track.Source.Microphone])
return (
<div className="space-y-2">
{/* Render audio tracks invisibly */}
{tracks.map(track => (
<AudioTrack key={track.participant.identity} trackRef={track} />
))}
{/* Participant cards */}
{participants.map(participant => {
const isLocal = participant.identity === localParticipant.identity
const isMuted = isLocal ? !isMicrophoneEnabled : participant.isMicrophoneEnabled === false
return (
<div
key={participant.identity}
className="flex items-center gap-3 p-3 rounded-lg bg-muted"
>
<div className={`size-10 rounded-full flex items-center justify-center text-white font-semibold ${participant.isSpeaking ? "bg-green-500" : "bg-primary"}`}>
{participant.name?.charAt(0) ?? "?"}
</div>
<div className="flex-1">
<p className="text-sm font-medium">{participant.name} {isLocal && "(You)"}</p>
{participant.isSpeaking && <p className="text-xs text-green-600">Speaking...</p>}
</div>
{isMuted && <span className="text-xs text-muted-foreground">🔇 Muted</span>}
</div>
)
})}
</div>
)
}
For the Daily.co alternative when a fully managed video API with a pre-built React call UI (<DailyProvider> + <DailyVideo>), recording, transcription, and network quality monitoring are offered as managed services without self-hosting — Daily.co trades LiveKit’s open-source flexibility for a turn-key solution, see the Daily.co guide. For the Agora alternative when a mature, battle-tested RTC SDK with ultra-low latency, massive concurrent user support, and extensive mobile SDK coverage is needed — Agora powers large-scale live streaming and interactive broadcasts where LiveKit targets smaller team video use cases, see the Agora guide. The Claude Skills 360 bundle includes LiveKit skill sets covering token auth, video grid, and screen sharing. Start with the free tier to try real-time video generation.