Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tricky-donkeys-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@livekit/component-example-next': patch
'@livekit/components-react': patch
---

Update nextjs examples with useSession/useAgent hooks
55 changes: 39 additions & 16 deletions examples/nextjs/pages/api/livekit/token.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { AccessToken } from 'livekit-server-sdk';
import type { AccessTokenOptions, VideoGrant } from 'livekit-server-sdk';
import type { TokenSourceRequestPayload, TokenSourceResponsePayload } from 'livekit-client';

type TokenSourceResponse = TokenSourceResponsePayload & {
participant_name?: string;
room_name?: string;
};

const apiKey = process.env.LK_API_KEY;
const apiSecret = process.env.LK_API_SECRET;
const serverUrl = process.env.NEXT_PUBLIC_LK_SERVER_URL || 'ws://localhost:7880';

const createToken = async (userInfo: AccessTokenOptions, grant: VideoGrant) => {
const at = new AccessToken(apiKey, apiSecret, userInfo);
Expand All @@ -13,26 +20,33 @@ const createToken = async (userInfo: AccessTokenOptions, grant: VideoGrant) => {

export default async function handleToken(req: NextApiRequest, res: NextApiResponse) {
try {
const { roomName, identity, name, metadata } = req.query;
if (!apiKey || !apiSecret) {
throw Error('LK_API_KEY and LK_API_SECRET must be set');
}

if (typeof identity !== 'string') {
throw Error('provide one (and only one) identity');
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
res.status(405).json({ error: 'Method Not Allowed. Only POST requests are supported.' });
return;
}
if (typeof roomName !== 'string') {
throw Error('provide one (and only one) roomName');

if (!req.body) {
throw Error('Request body is required');
}

if (Array.isArray(name)) {
throw Error('provide max one name');
const body = req.body as TokenSourceRequestPayload;
if (!body.room_name) {
throw Error('room_name is required');
}
if (Array.isArray(metadata)) {
throw Error('provide max one metadata string');
if (!body.participant_identity) {
throw Error('participant_identity is required');
}

// if (!userSession.isAuthenticated) {
// res.status(403).end();
// return;
// }
const roomName = body.room_name;
const identity = body.participant_identity;
const name = body.participant_name;
const metadata = body.participant_metadata;

const grant: VideoGrant = {
room: roomName,
roomJoin: true,
Expand All @@ -43,9 +57,18 @@ export default async function handleToken(req: NextApiRequest, res: NextApiRespo
};
const token = await createToken({ identity, name, metadata }, grant);

res.status(200).json({ identity, accessToken: token });
// Return response in TokenSourceResponse format (snake_case)
const response: TokenSourceResponse = {
server_url: serverUrl,
participant_token: token,
participant_name: name,
room_name: roomName,
};
res.status(200).json(response);
} catch (e) {
res.statusMessage = (e as Error).message;
res.status(500).end();
const errorMessage = (e as Error).message;
console.error('Token generation error:', errorMessage);
res.statusMessage = errorMessage;
res.status(500).json({ error: errorMessage });
}
}
75 changes: 59 additions & 16 deletions examples/nextjs/pages/audio-only.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,75 @@
'use client';

import { AudioConference, LiveKitRoom, useToken } from '@livekit/components-react';
import {
AudioConference,
SessionProvider,
useSession,
SessionEvent,
} from '@livekit/components-react';
import type { NextPage } from 'next';
import { generateRandomUserId } from '../lib/helper';
import { useState } from 'react';
import { useMemo, useState, useEffect } from 'react';
import { TokenSource, MediaDeviceFailure } from 'livekit-client';

const AudioExample: NextPage = () => {
const params = typeof window !== 'undefined' ? new URLSearchParams(location.search) : null;
const params = useMemo(
() => (typeof window !== 'undefined' ? new URLSearchParams(location.search) : null),
[],
);
const roomName = params?.get('room') ?? 'test-room';
const [userIdentity] = useState(params?.get('user') ?? generateRandomUserId());
const [userIdentity] = useState(() => params?.get('user') ?? generateRandomUserId());

const tokenSource = useMemo(() => {
return TokenSource.endpoint(process.env.NEXT_PUBLIC_LK_TOKEN_ENDPOINT!);
}, []);
Comment on lines +22 to +24
Copy link
Contributor

@1egoman 1egoman Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I think if at all possible, the examples (where it can be done) should define the TokenSource outside the component as a constant rather than in the component in a memoized fashion.

Making sure that the TokenSource doesn't change reference proved to be a challenge when Thom was porting over agent-starter-react to use these new apis, and I'd like to try to push people away from injecting local component state into TokenSources (configurable token sources should include all the metadata somebody would need in TokenSource.custom cases) unless you are doing something truly weird like agent-starter-react does to handle sandbox environments properly.


const token = useToken(process.env.NEXT_PUBLIC_LK_TOKEN_ENDPOINT, roomName, {
userInfo: {
identity: userIdentity,
name: userIdentity,
},
const session = useSession(tokenSource, {
roomName,
participantIdentity: userIdentity,
participantName: userIdentity,
});

useEffect(() => {
session
.start({
tracks: {
microphone: { enabled: true },
},
roomConnectOptions: {
autoSubscribe: true,
},
})
.catch((err) => {
console.error('Failed to start session:', err);
});
return () => {
session.end().catch((err) => {
console.error('Failed to end session:', err);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.start, session.end]);

useEffect(() => {
const handleMediaDevicesError = (error: Error) => {
const failure = MediaDeviceFailure.getFailure(error);
console.error(failure);
alert(
'Error acquiring camera or microphone permissions. Please make sure you grant the necessary permissions in your browser and reload the tab',
);
};

session.internal.emitter.on(SessionEvent.MediaDevicesError, handleMediaDevicesError);
return () => {
session.internal.emitter.off(SessionEvent.MediaDevicesError, handleMediaDevicesError);
};
}, [session]);

return (
<div data-lk-theme="default">
<LiveKitRoom
video={false}
audio={true}
token={token}
serverUrl={process.env.NEXT_PUBLIC_LK_SERVER_URL}
>
<SessionProvider session={session}>
<AudioConference />
</LiveKitRoom>
</SessionProvider>
</div>
);
};
Expand Down
99 changes: 65 additions & 34 deletions examples/nextjs/pages/clubhouse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,87 @@

import {
ControlBar,
LiveKitRoom,
SessionProvider,
useSession,
RoomAudioRenderer,
RoomName,
TrackLoop,
TrackMutedIndicator,
useIsMuted,
useIsSpeaking,
useToken,
useTrackRefContext,
useTracks,
SessionEvent,
} from '@livekit/components-react';
import styles from '../styles/Clubhouse.module.scss';
import { Track } from 'livekit-client';
import { useMemo, useState } from 'react';
import { Track, TokenSource, MediaDeviceFailure } from 'livekit-client';
import { useMemo, useState, useEffect } from 'react';
import { generateRandomUserId } from '../lib/helper';
import type { NextPage } from 'next';

const Clubhouse = () => {
const params = typeof window !== 'undefined' ? new URLSearchParams(location.search) : null;
const Clubhouse: NextPage = () => {
const params = useMemo(
() => (typeof window !== 'undefined' ? new URLSearchParams(location.search) : null),
[],
);
const roomName = params?.get('room') ?? 'test-room';
const userIdentity = params?.get('user') ?? generateRandomUserId();
const [userIdentity] = useState(() => params?.get('user') ?? generateRandomUserId());

const tokenSource = useMemo(() => {
return TokenSource.endpoint(process.env.NEXT_PUBLIC_LK_TOKEN_ENDPOINT!);
}, []);

const token = useToken(process.env.NEXT_PUBLIC_LK_TOKEN_ENDPOINT, roomName, {
userInfo: {
identity: userIdentity,
name: userIdentity,
},
const session = useSession(tokenSource, {
roomName,
participantIdentity: userIdentity,
participantName: userIdentity,
});

const [tryToConnect, setTryToConnect] = useState(false);
const [connected, setConnected] = useState(false);

useEffect(() => {
if (tryToConnect) {
session
.start({
tracks: {
microphone: { enabled: true },
},
})
.catch((err) => {
console.error('Failed to start session:', err);
});
} else {
session.end().catch((err) => {
console.error('Failed to end session:', err);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought(non-blocking): 😢

Copy link
Contributor

@1egoman 1egoman Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also a thought:, but maybe it could make sense to destructure the session to get those start / end values out? I think you had said a few weeks back that does silence the warning?

}, [tryToConnect, session.start, session.end]);

useEffect(() => {
if (session.connectionState === 'disconnected') {
setTryToConnect(false);
}
}, [session.connectionState]);

useEffect(() => {
const handleMediaDevicesError = (error: Error) => {
const failure = MediaDeviceFailure.getFailure(error);
console.error(failure);
alert(
'Error acquiring camera or microphone permissions. Please make sure you grant the necessary permissions in your browser and reload the tab',
);
};

session.internal.emitter.on(SessionEvent.MediaDevicesError, handleMediaDevicesError);
Copy link
Contributor

@lukasIO lukasIO Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: @1egoman maybe we should think about a cleaner high level helper for this.

using the internal emitters in the examples doesn't feel great.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a better way to do this proposed, useEvents, though it didn't get much discussion because I figured it was fairly non controversial - https://github.com/livekit/components-js/blob/main/packages/react/src/hooks/useEvents.ts#L5.

So the thinking is the above could be something like the below instead:

useEvents(session, SessionEvent.MediaDevicesError, handleMediaDevicesError);

return () => {
session.internal.emitter.off(SessionEvent.MediaDevicesError, handleMediaDevicesError);
};
}, [session]);

return (
<div data-lk-theme="default" className={styles.container}>
<LiveKitRoom
token={token}
serverUrl={process.env.NEXT_PUBLIC_LK_SERVER_URL}
connect={tryToConnect}
video={false}
audio={true}
// simulateParticipants={15}
onConnected={() => setConnected(true)}
onDisconnected={() => {
setTryToConnect(false);
setConnected(false);
}}
>
<SessionProvider session={session}>
<div style={{ display: 'grid', placeContent: 'center', height: '100%' }}>
<button
className="lk-button"
Expand All @@ -59,7 +94,7 @@
</button>
</div>

<div className={styles.slider} style={{ bottom: connected ? '0px' : '-100%' }}>
<div className={styles.slider} style={{ bottom: session.isConnected ? '0px' : '-100%' }}>
<h1>
<RoomName />
</h1>
Expand All @@ -70,15 +105,15 @@
/>
<RoomAudioRenderer />
</div>
</LiveKitRoom>
</SessionProvider>
</div>
);
};

const Stage = () => {
const tracksReferences = useTracks([Track.Source.Microphone]);
return (
<div className="">
<div>
<div className={styles.stageGrid}>
<TrackLoop tracks={tracksReferences}>
<CustomParticipantTile></CustomParticipantTile>
Expand All @@ -98,14 +133,10 @@
return (
<section className={styles['participant-tile']} title={trackRef.participant.name}>
<div
// className={`rounded-full border-2 p-0.5 transition-colors duration-1000 ${
className={styles['avatar-container']}
style={{ borderColor: isSpeaking ? 'greenyellow' : 'transparent' }}
>
<div
className={styles.avatar}
// className="z-10 grid aspect-square items-center overflow-hidden rounded-full bg-beige transition-all will-change-transform"
>
<div className={styles.avatar}>
<img
src={`https://avatars.dicebear.com/api/avataaars/${id}.svg?mouth=default,smile,tongue&eyes=default,happy,hearts&eyebrows=default,defaultNatural,flatNatural`}
className="fade-in"
Expand Down
Loading