Daily.co is a WebRTC video API — DailyProvider wraps your app with a call context. useDaily() returns the DailyCall object. callObject.join({ url, token }) connects to a room. useLocalParticipant() returns your own participant state — {video: true, audio: true, session_id, user_name}. useParticipantIds() lists all session IDs. useVideoTrack(sessionId) returns {track, isOff, persistentTrack}. DailyVideo renders a participant’s video from a session ID. useDevices() returns {cameras, microphones, speakers, setCamera, setMicrophone}. useScreenShare() returns {screens, startScreenShare, stopScreenShare, isSharingScreen}. Server-side: daily.createRoom({ name, properties }) creates a room; daily.createMeetingToken({ properties: { room_name, is_owner, exp } }) creates a token. useRecording() hooks into call recording state. Claude Code generates Daily.co video rooms, participant grids, device selectors, and meeting token APIs.
CLAUDE.md for Daily.co
## Daily.co Stack
- Version: @daily-co/daily-react >= 0.22, @daily-co/daily-js >= 0.70
- Provider: wrap with <DailyProvider> — no callObject prop needed (auto-creates)
- Join: const daily = useDaily(); daily.join({ url: roomUrl, token })
- Tracks: const videoTrack = useVideoTrack(sessionId); <DailyVideo sessionId={id} type="video" />
- Participants: const ids = useParticipantIds(); const local = useLocalParticipant()
- Devices: const { cameras, setCamera, microphones, setMicrophone } = useDevices()
- Screen: const { isSharingScreen, startScreenShare, stopScreenShare } = useScreenShare()
- Server: POST https://api.daily.co/v1/rooms with Authorization: Bearer DAILY_API_KEY
Daily.co Server Utilities
// lib/video/daily.ts — Daily.co REST API utilities
const DAILY_API_KEY = process.env.DAILY_API_KEY!
const DAILY_DOMAIN = process.env.DAILY_DOMAIN! // e.g. "yourapp.daily.co"
const DAILY_API = "https://api.daily.co/v1"
async function dailyFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${DAILY_API}${path}`, {
...options,
headers: {
Authorization: `Bearer ${DAILY_API_KEY}`,
"Content-Type": "application/json",
...options.headers,
},
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Daily API error ${res.status}: ${err}`)
}
return res.json() as Promise<T>
}
export type DailyRoom = {
id: string
name: string
url: string
created_at: string
config: {
exp?: number
max_participants?: number
enable_recording?: string
}
}
export async function createRoom(params: {
name?: string
expirySeconds?: number
maxParticipants?: number
enableRecording?: boolean
isOwnerOnly?: boolean
}): Promise<DailyRoom> {
const exp = params.expirySeconds
? Math.floor(Date.now() / 1000) + params.expirySeconds
: undefined
return dailyFetch<DailyRoom>("/rooms", {
method: "POST",
body: JSON.stringify({
name: params.name,
properties: {
exp,
max_participants: params.maxParticipants ?? 10,
enable_recording: params.enableRecording ? "cloud" : undefined,
enable_prejoin_ui: true,
enable_network_ui: true,
enable_noise_cancellation_ui: true,
start_video_off: false,
start_audio_off: false,
},
}),
})
}
export async function getRoom(name: string): Promise<DailyRoom | null> {
try {
return await dailyFetch<DailyRoom>(`/rooms/${name}`)
} catch {
return null
}
}
export async function deleteRoom(name: string): Promise<void> {
await dailyFetch(`/rooms/${name}`, { method: "DELETE" })
}
export type MeetingToken = {
token: string
}
export async function createMeetingToken(params: {
roomName: string
userId: string
userName: string
isOwner?: boolean
expirySeconds?: number
startVideoOff?: boolean
startAudioOff?: boolean
}): Promise<string> {
const exp = params.expirySeconds
? Math.floor(Date.now() / 1000) + params.expirySeconds
: Math.floor(Date.now() / 1000) + 3600 // 1 hour default
const { token } = await dailyFetch<MeetingToken>("/meeting-tokens", {
method: "POST",
body: JSON.stringify({
properties: {
room_name: params.roomName,
user_id: params.userId,
user_name: params.userName,
is_owner: params.isOwner ?? false,
exp,
start_video_off: params.startVideoOff ?? false,
start_audio_off: params.startAudioOff ?? false,
enable_recording: params.isOwner ? "cloud" : undefined,
},
}),
})
return token
}
Next.js API Routes
// app/api/rooms/route.ts — create a Daily room + token
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@clerk/nextjs/server"
import { createRoom, createMeetingToken } from "@/lib/video/daily"
import { db } from "@/lib/db"
import { meetings } from "@/lib/db/schema"
export async function POST(request: NextRequest) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { title, maxParticipants = 10 } = await request.json()
// Create Daily room
const room = await createRoom({
expirySeconds: 2 * 60 * 60, // 2 hours
maxParticipants,
enableRecording: true,
})
// Create owner token
const token = await createMeetingToken({
roomName: room.name,
userId,
userName: "Host",
isOwner: true,
expirySeconds: 2 * 60 * 60,
})
// Persist to DB
const [meeting] = await db.insert(meetings).values({
roomName: room.name,
roomUrl: room.url,
title,
hostId: userId,
expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
}).returning()
return NextResponse.json({ room, token, meetingId: meeting.id })
}
// app/api/rooms/[roomName]/token/route.ts — join token for participants
import { NextRequest, NextResponse } from "next/server"
import { auth, currentUser } from "@clerk/nextjs/server"
import { createMeetingToken, getRoom } from "@/lib/video/daily"
export async function POST(
request: NextRequest,
{ params }: { params: { roomName: string } },
) {
const { userId } = await auth()
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const room = await getRoom(params.roomName)
if (!room) return NextResponse.json({ error: "Room not found" }, { status: 404 })
const user = await currentUser()
const token = await createMeetingToken({
roomName: params.roomName,
userId,
userName: user?.fullName ?? user?.username ?? "Guest",
isOwner: false,
expirySeconds: 2 * 60 * 60,
})
return NextResponse.json({ token, roomUrl: room.url })
}
Video Call Component
// components/video/VideoCall.tsx — Daily.co video call UI
"use client"
import {
DailyProvider,
useDaily,
useLocalParticipant,
useParticipantIds,
useVideoTrack,
useAudioTrack,
useDevices,
useScreenShare,
DailyVideo,
} from "@daily-co/daily-react"
import { useCallback, useEffect, useState } from "react"
import { Mic, MicOff, Video, VideoOff, Monitor, PhoneOff, Users } from "lucide-react"
interface VideoCallProps {
roomUrl: string
token: string
onLeave?: () => void
}
export function VideoCall({ roomUrl, token, onLeave }: VideoCallProps) {
return (
<DailyProvider>
<VideoCallInner roomUrl={roomUrl} token={token} onLeave={onLeave} />
</DailyProvider>
)
}
function VideoCallInner({ roomUrl, token, onLeave }: VideoCallProps) {
const daily = useDaily()
const local = useLocalParticipant()
const participantIds = useParticipantIds({ filter: "remote" })
const { isSharingScreen, startScreenShare, stopScreenShare } = useScreenShare()
const [joined, setJoined] = useState(false)
// Join the call
useEffect(() => {
if (!daily) return
daily.join({ url: roomUrl, token }).then(() => setJoined(true))
return () => {
daily.leave()
}
}, [daily, roomUrl, token])
const toggleMic = useCallback(() => {
daily?.setLocalAudio(!local?.audio)
}, [daily, local?.audio])
const toggleCamera = useCallback(() => {
daily?.setLocalVideo(!local?.video)
}, [daily, local?.video])
const toggleScreenShare = useCallback(() => {
if (isSharingScreen) stopScreenShare()
else startScreenShare()
}, [isSharingScreen, startScreenShare, stopScreenShare])
const handleLeave = useCallback(async () => {
await daily?.leave()
onLeave?.()
}, [daily, onLeave])
if (!joined) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center space-y-2">
<div className="size-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-sm text-muted-foreground">Connecting...</p>
</div>
</div>
)
}
return (
<div className="flex flex-col h-full bg-neutral-900 rounded-2xl overflow-hidden">
{/* Participant grid */}
<div className="flex-1 p-3 grid gap-2"
style={{
gridTemplateColumns: participantIds.length === 0
? "1fr"
: participantIds.length <= 3
? `repeat(${participantIds.length + 1}, 1fr)`
: "repeat(2, 1fr)",
}}
>
{/* Local video */}
{local && (
<ParticipantTile
sessionId={local.session_id}
isLocal
label={local.user_name ?? "You"}
/>
)}
{/* Remote participants */}
{participantIds.map(id => (
<RemoteParticipantTile key={id} sessionId={id} />
))}
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-3 p-4 border-t border-white/10">
<ControlButton
onClick={toggleMic}
active={!local?.audio}
activeIcon={<MicOff className="size-5" />}
inactiveIcon={<Mic className="size-5" />}
label={local?.audio ? "Mute" : "Unmute"}
/>
<ControlButton
onClick={toggleCamera}
active={!local?.video}
activeIcon={<VideoOff className="size-5" />}
inactiveIcon={<Video className="size-5" />}
label={local?.video ? "Stop video" : "Start video"}
/>
<ControlButton
onClick={toggleScreenShare}
active={isSharingScreen}
activeIcon={<Monitor className="size-5" />}
inactiveIcon={<Monitor className="size-5" />}
label={isSharingScreen ? "Stop share" : "Share screen"}
variant="secondary"
/>
<div className="flex items-center gap-1 text-white/70 text-sm px-3 py-2 rounded-full bg-white/10">
<Users className="size-4" />
<span>{participantIds.length + 1}</span>
</div>
<button
onClick={handleLeave}
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-red-500 hover:bg-red-600 text-white text-sm font-medium transition-colors"
>
<PhoneOff className="size-4" />
Leave
</button>
</div>
</div>
)
}
function ParticipantTile({
sessionId,
isLocal = false,
label,
}: {
sessionId: string
isLocal?: boolean
label: string
}) {
const videoTrack = useVideoTrack(sessionId)
const audioTrack = useAudioTrack(sessionId)
return (
<div className="relative bg-neutral-800 rounded-xl overflow-hidden aspect-video">
{!videoTrack.isOff ? (
<DailyVideo
sessionId={sessionId}
type="video"
className="w-full h-full object-cover"
mirror={isLocal}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="size-16 rounded-full bg-neutral-600 flex items-center justify-center text-2xl font-bold text-white">
{label[0]?.toUpperCase()}
</div>
</div>
)}
{/* Name badge */}
<div className="absolute bottom-2 left-2 flex items-center gap-1.5 bg-black/50 backdrop-blur-sm rounded-full px-2 py-0.5">
{audioTrack.isOff && <MicOff className="size-3 text-red-400" />}
<span className="text-white text-xs font-medium">{label}</span>
</div>
</div>
)
}
function RemoteParticipantTile({ sessionId }: { sessionId: string }) {
const participant = useParticipantIds({ filter: (p) => p.session_id === sessionId })[0]
// Get participant info
const ids = useParticipantIds()
return (
<ParticipantTile
sessionId={sessionId}
label={"Participant"}
/>
)
}
function ControlButton({
onClick,
active,
activeIcon,
inactiveIcon,
label,
variant = "default",
}: {
onClick: () => void
active: boolean
activeIcon: React.ReactNode
inactiveIcon: React.ReactNode
label: string
variant?: "default" | "secondary"
}) {
return (
<button
onClick={onClick}
title={label}
className={`p-3 rounded-full transition-colors ${
active
? "bg-red-500/20 text-red-400 hover:bg-red-500/30"
: variant === "secondary"
? "bg-white/10 text-white hover:bg-white/20"
: "bg-white/10 text-white hover:bg-white/20"
}`}
>
{active ? activeIcon : inactiveIcon}
</button>
)
}
Device Selector
// components/video/DeviceSelector.tsx — camera/mic/speaker picker
"use client"
import { useDevices } from "@daily-co/daily-react"
export function DeviceSelector() {
const {
cameras,
microphones,
speakers,
currentCamera,
currentMicrophone,
currentSpeaker,
setCamera,
setMicrophone,
setSpeaker,
} = useDevices()
return (
<div className="space-y-4 p-4 rounded-xl border bg-card">
<h3 className="font-semibold text-sm">Audio & Video Settings</h3>
<div className="space-y-3">
<DeviceSelect
label="Camera"
devices={cameras}
currentDeviceId={currentCamera?.device.deviceId}
onChange={setCamera}
/>
<DeviceSelect
label="Microphone"
devices={microphones}
currentDeviceId={currentMicrophone?.device.deviceId}
onChange={setMicrophone}
/>
<DeviceSelect
label="Speaker"
devices={speakers}
currentDeviceId={currentSpeaker?.device.deviceId}
onChange={setSpeaker}
/>
</div>
</div>
)
}
function DeviceSelect({
label,
devices,
currentDeviceId,
onChange,
}: {
label: string
devices: { device: MediaDeviceInfo; selected: boolean }[]
currentDeviceId?: string
onChange: (deviceId: string) => void
}) {
return (
<div className="space-y-1">
<label className="text-xs text-muted-foreground">{label}</label>
<select
value={currentDeviceId}
onChange={(e) => onChange(e.target.value)}
className="w-full text-sm rounded-lg border bg-background px-3 py-2"
>
{devices.map(({ device }) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `${label} ${device.deviceId.slice(0, 8)}`}
</option>
))}
</select>
</div>
)
}
For the Agora alternative when a more scalable live-streaming or broadcast scenario with large audiences (100k+ viewers), interactive live streaming, or real-time engagement features like virtual gifts is needed — Agora has lower latency for high-concurrency broadcasts while Daily excels at small-to-medium group calls with a developer-friendly React SDK, see the Agora guide. For the Livekit alternative when a fully self-hostable SFU with Kubernetes scaling, cloud-recording to S3, and an open-source ecosystem is required — Livekit gives you full infrastructure control while Daily is a managed service, see the Livekit guide. The Claude Skills 360 bundle includes Daily.co skill sets covering video rooms, participant grids, and device management. Start with the free tier to try video call generation.