Claude
Skills
Sign in
Back

video-room

Included with Lifetime
$97 forever

LiveKit video room infrastructure — server client, JWT token generation, room management API routes, and a client-side hook for joining rooms. Use this skill when the user says "add video", "setup video rooms", "add livekit", "video calling", or "setup video-room".

Image & Video

What this skill does


# Video Room

LiveKit-powered video room infrastructure with server-side room management, JWT token generation secured by better-auth, and a client-side hook for joining rooms and managing local media tracks.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `auth` skill installed (`withAuth` available at `@/lib/auth-guard`)
- `env-config` skill installed (`src/env.ts` with Zod schema)
- LiveKit Cloud account or self-hosted LiveKit server

## Installation

```bash
bun add livekit-client livekit-server-sdk @livekit/components-react @livekit/components-styles
```

## Environment Variables

Add to `.env.local`:

```env
# LiveKit
LIVEKIT_API_KEY=your-api-key
LIVEKIT_API_SECRET=your-api-secret
NEXT_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud
```

### Update `src/env.ts`

Add to the `server` object:

```typescript
server: {
  // ... existing variables
  LIVEKIT_API_KEY: z.string().min(1),
  LIVEKIT_API_SECRET: z.string().min(1),
},
```

Add to the `client` object:

```typescript
client: {
  // ... existing variables
  NEXT_PUBLIC_LIVEKIT_URL: z.string().url(),
},
```

Add to the `runtimeEnv` object:

```typescript
runtimeEnv: {
  // ... existing variables
  LIVEKIT_API_KEY: process.env.LIVEKIT_API_KEY,
  LIVEKIT_API_SECRET: process.env.LIVEKIT_API_SECRET,
  NEXT_PUBLIC_LIVEKIT_URL: process.env.NEXT_PUBLIC_LIVEKIT_URL,
},
```

## What Gets Created

```
src/
├── lib/
│   └── video/
│       ├── livekit.ts        # Server-side LiveKit RoomServiceClient
│       ├── types.ts          # Room, Participant, Track, and grant types
│       ├── token.ts          # Server-side JWT token generation
│       └── use-room.ts       # Client hook for joining rooms + managing local tracks
└── app/
    └── api/
        └── video/
            ├── rooms/
            │   └── route.ts  # POST create room, GET list rooms
            └── token/
                └── route.ts  # POST generate participant token (auth required)
```

## Setup Steps

### Step 1: Create `src/lib/video/types.ts`

```typescript
import type { Track as LKTrack } from "livekit-client";

export type VideoRoom = {
  name: string;
  sid: string;
  numParticipants: number;
  maxParticipants: number;
  creationTime: number;
  metadata: string;
};

export type VideoParticipant = {
  sid: string;
  identity: string;
  name: string;
  metadata: string;
  joinedAt: number;
  isSpeaking: boolean;
  connectionQuality: string;
};

export type VideoTrack = {
  sid: string;
  type: LKTrack.Kind;
  source: LKTrack.Source;
  muted: boolean;
  width?: number;
  height?: number;
};

export type TokenGrants = {
  canPublish: boolean;
  canSubscribe: boolean;
  canPublishData: boolean;
};

export type CreateRoomRequest = {
  name: string;
  maxParticipants?: number;
  metadata?: string;
  emptyTimeout?: number;
};

export type CreateRoomResponse = {
  room: VideoRoom;
};

export type TokenRequest = {
  roomName: string;
  participantName?: string;
  grants?: Partial<TokenGrants>;
};

export type TokenResponse = {
  token: string;
  url: string;
};

export type RoomListResponse = {
  rooms: VideoRoom[];
};
```

### Step 2: Create `src/lib/video/livekit.ts`

```typescript
import { RoomServiceClient } from "livekit-server-sdk";

function getLiveKitCredentials(): { apiKey: string; apiSecret: string; wsUrl: string } {
  const apiKey = process.env.LIVEKIT_API_KEY;
  const apiSecret = process.env.LIVEKIT_API_SECRET;
  const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL;

  if (!apiKey) throw new Error("LIVEKIT_API_KEY is not set");
  if (!apiSecret) throw new Error("LIVEKIT_API_SECRET is not set");
  if (!wsUrl) throw new Error("NEXT_PUBLIC_LIVEKIT_URL is not set");

  return { apiKey, apiSecret, wsUrl };
}

let roomServiceClient: RoomServiceClient | null = null;

export function getRoomServiceClient(): RoomServiceClient {
  if (roomServiceClient) return roomServiceClient;

  const { apiKey, apiSecret, wsUrl } = getLiveKitCredentials();

  // Convert wss:// to https:// for REST API
  const httpUrl = wsUrl.replace("wss://", "https://").replace("ws://", "http://");

  roomServiceClient = new RoomServiceClient(httpUrl, apiKey, apiSecret);
  return roomServiceClient;
}

export { getLiveKitCredentials };
```

### Step 3: Create `src/lib/video/token.ts`

```typescript
import { AccessToken } from "livekit-server-sdk";
import { getLiveKitCredentials } from "@/lib/video/livekit";
import type { TokenGrants } from "@/lib/video/types";

type GenerateTokenParams = {
  roomName: string;
  participantIdentity: string;
  participantName: string;
  grants?: Partial<TokenGrants>;
  metadata?: string;
  ttlSeconds?: number;
};

const DEFAULT_GRANTS: TokenGrants = {
  canPublish: true,
  canSubscribe: true,
  canPublishData: true,
};

export async function generateParticipantToken({
  roomName,
  participantIdentity,
  participantName,
  grants,
  metadata,
  ttlSeconds = 3600,
}: GenerateTokenParams): Promise<string> {
  const { apiKey, apiSecret } = getLiveKitCredentials();

  const mergedGrants: TokenGrants = {
    ...DEFAULT_GRANTS,
    ...grants,
  };

  const token = new AccessToken(apiKey, apiSecret, {
    identity: participantIdentity,
    name: participantName,
    metadata,
    ttl: ttlSeconds,
  });

  token.addGrant({
    room: roomName,
    roomJoin: true,
    canPublish: mergedGrants.canPublish,
    canSubscribe: mergedGrants.canSubscribe,
    canPublishData: mergedGrants.canPublishData,
  });

  return await token.toJwt();
}
```

### Step 4: Create `src/lib/video/use-room.ts`

```typescript
"use client";

import { useState, useCallback, useRef, useEffect } from "react";
import {
  Room,
  RoomEvent,
  Track,
  type LocalTrackPublication,
  type RoomOptions,
  type RoomConnectOptions,
} from "livekit-client";
import type { TokenResponse } from "@/lib/video/types";

type UseRoomOptions = {
  roomName: string;
  participantName?: string;
  autoConnect?: boolean;
  roomOptions?: RoomOptions;
  connectOptions?: RoomConnectOptions;
};

type UseRoomReturn = {
  room: Room | null;
  isConnecting: boolean;
  isConnected: boolean;
  error: string | null;
  connect: () => Promise<void>;
  disconnect: () => void;
  toggleMicrophone: () => Promise<void>;
  toggleCamera: () => Promise<void>;
  isMicEnabled: boolean;
  isCameraEnabled: boolean;
};

export function useRoom({
  roomName,
  participantName,
  autoConnect = false,
  roomOptions,
  connectOptions,
}: UseRoomOptions): UseRoomReturn {
  const [room, setRoom] = useState<Room | null>(null);
  const [isConnecting, setIsConnecting] = useState(false);
  const [isConnected, setIsConnected] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [isMicEnabled, setIsMicEnabled] = useState(true);
  const [isCameraEnabled, setIsCameraEnabled] = useState(true);
  const roomRef = useRef<Room | null>(null);

  const fetchToken = useCallback(async (): Promise<TokenResponse> => {
    const res = await fetch("/api/video/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        roomName,
        participantName,
      }),
    });

    if (!res.ok) {
      const body = await res.json().catch(() => ({ error: "Failed to get token" }));
      throw new Error((body as { error: string }).error);
    }

    return res.json() as Promise<TokenResponse>;
  }, [roomName, participantName]);

  const connect = useCallback(async () => {
    if (roomRef.current?.state === "connected") return;

    setIsConnecting(true);
    setError(null);

    try {
      const { token, url } = await fetchToken();

      const newRoom = new Room(roomOptions);
      roomRef.current = newRoom;

      newRoom.on(RoomEvent.Connected, () => {
        setIsConnected(true);
        setIsConnecting(false);
      });

      newRoom.on(RoomEvent.Disconnected, () => {
        setIsConnected(false);
      });

      newRoom.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
        if (publication.track?.sou
Files: 1
Size: 18.8 KB
Complexity: 28/100
Category: Image & Video

Related in Image & Video