A Next.js app holds both halves of a Livqeno integration in one project:
- Server code (a Route Handler) holds your API key and mints a short-lived token, called the grant, for the user who is signed in.
- Client code (a Client Component) takes that grant and joins the call or chat in the browser.
This page walks you from an empty App Router project to a working video call, then adds chat. New to tokens? Read What is Livqeno? first. It takes about two minutes.
Quick start
- Install the server, browser and React packages.
- Put
LIVQENO_API_KEYandLIVQENO_API_URLin.env.local. - Add a Route Handler that mints a grant, and a Client Component that joins with it.
pnpm add @livqeno/server @livqeno/rtc @livqeno/reactnpm install @livqeno/server @livqeno/rtc @livqeno/reactyarn add @livqeno/server @livqeno/rtc @livqeno/reactbun add @livqeno/server @livqeno/rtc @livqeno/reactRequirements
| Minimum | |
|---|---|
| Next.js | App Router (13.4 or later) |
| React | ^18 or ^19 |
| Node.js (server) | 20 |
| Browser | Any browser with WebRTC. See Browser support |
The Pages Router works too. The Route Handler becomes an API route
(pages/api/join-room.ts) and the rest of this page is unchanged.
Packages
| Package | Runs on | Use it for |
|---|---|---|
@livqeno/server | Server only | Creating rooms and minting tokens with your API key |
@livqeno/react | Browser | <LivqenoRoom> and hooks for calls, chat and live streams |
@livqeno/rtc | Browser | The call client underneath @livqeno/react, or on its own without React |
@livqeno/chat | Browser | Chat on its own |
@livqeno/live | Browser | Calls and chat behind one object |
Install only what you use. @livqeno/rtc and @livqeno/chat don't depend
on each other, so a calls-only app never ships chat code.
Set your environment variables
Get an API key from the dashboard (Create a project),
then add it to .env.local in the project root:
LIVQENO_API_KEY=rvk_dev_...
LIVQENO_API_URL=https://api.livqeno.comProject structure
app/
├── api/
│ ├── join-room/
│ │ └── route.ts ← mints the call grant (server)
│ └── chat-token/
│ └── route.ts ← mints the chat token (server)
└── call/
├── page.tsx ← Server Component
└── call-client.tsx ← 'use client': <LivqenoRoom> and hooks
lib/
└── livqeno.ts ← the one shared Livqeno instance (server)Create the server client
Create one Livqeno instance and share it. The server SDK doesn't read
environment variables on its own, so pass both values in:
import { Livqeno } from '@livqeno/server';
export const livqeno = new Livqeno({
apiKey: process.env.LIVQENO_API_KEY!,
baseUrl: process.env.LIVQENO_API_URL!,
});Mint a grant in a Route Handler
A Route Handler is server code, so it's the right place for your API key. It checks who is signed in, gets or creates the room, and returns the grant:
import { livqeno } from '@/lib/livqeno';
import { getCurrentUser } from '@/lib/auth'; // your auth: NextAuth, Clerk, Supabase…
export async function POST() {
const user = await getCurrentUser();
if (!user) return new Response('Unauthorized', { status: 401 });
// Every participant calls this. getOrCreate hands the second caller the
// same room instead of a 409.
const room = await livqeno.rooms.create({ name: 'standup', getOrCreate: true });
const grant = await livqeno.tokens.create({
room: room.id,
identity: user.id, // from your session, never from the request body
permissions: { join: true, publish: true, subscribe: true },
expiresIn: 600,
});
return Response.json(grant); // forward the whole object
}The identity comes from your session. Taking it from the request body would let anyone join as anyone.
Join from a Client Component
<LivqenoRoom> and every hook need the camera, the microphone and the
browser's WebRTC support, which don't exist while Next.js renders on the
server. So they go in a file marked 'use client':
'use client';
import { useEffect, useState } from 'react';
import {
LivqenoRoom,
ParticipantView,
useCamera,
useConnectionState,
useLocalParticipant,
useRemoteParticipants,
} from '@livqeno/react';
type Grant = {
token: string;
endpoint: string;
iceServers: RTCIceServer[];
telemetryUrl?: string;
roomName: string;
};
export function CallClient() {
const [grant, setGrant] = useState<Grant>();
useEffect(() => {
fetch('/api/join-room', { method: 'POST' })
.then((res) => res.json())
.then(setGrant);
}, []);
if (!grant) return <p>Getting a token…</p>;
return (
<LivqenoRoom
token={grant.token}
endpoint={grant.endpoint}
iceServers={grant.iceServers}
telemetryUrl={grant.telemetryUrl}
room={grant.roomName}
fallback={<p>Connecting…</p>}
>
<Call />
</LivqenoRoom>
);
}
function Call() {
const state = useConnectionState();
const local = useLocalParticipant();
const remote = useRemoteParticipants();
const camera = useCamera();
return (
<div>
<p>Status: {state}</p>
<button onClick={() => (camera.enabled ? camera.disable() : camera.enable())}>
Camera: {camera.enabled ? 'on' : 'off'}
</button>
{local && <ParticipantView participant={local} />}
{remote.map((p) => (
<ParticipantView key={p.identity} participant={p} />
))}
</div>
);
}Always pass iceServers. It holds the relay (TURN) details that make calls
work on mobile data and strict office networks.
Render it from a page
The page itself can stay a Server Component. It just renders the client component:
import { CallClient } from './call-client';
export default function Page() {
return <CallClient />;
}Run npm run dev, open /call in two browser windows signed in as two
different users, and they see and hear each other.
Add chat
Chat uses its own token. Mint it in a second Route Handler, after adding the user to the conversation, since a chat token only reaches conversations its user belongs to:
pnpm add @livqeno/chatnpm install @livqeno/chatyarn add @livqeno/chatbun add @livqeno/chatimport { livqeno } from '@/lib/livqeno';
import { getCurrentUser } from '@/lib/auth';
export async function POST() {
const user = await getCurrentUser();
if (!user) return new Response('Unauthorized', { status: 401 });
await livqeno.chat.createConversation({ name: 'general', getOrCreate: true });
await livqeno.chat.addMember('general', { userId: user.id });
const chatGrant = await livqeno.chat.createToken({
userId: user.id,
conversations: ['general'],
});
return Response.json(chatGrant);
}Then connect in a Client Component:
'use client';
import { useEffect } from 'react';
import { createChatClient } from '@livqeno/chat';
export function ChatClient() {
useEffect(() => {
let chat: ReturnType<typeof createChatClient> | undefined;
fetch('/api/chat-token', { method: 'POST' })
.then((res) => res.json())
.then(async (grant) => {
chat = createChatClient({ token: grant.token, apiUrl: grant.apiUrl });
await chat.connect({ room: 'general' });
chat.on('message', (m) => console.log(`${m.senderId}: ${m.text}`));
});
return () => chat?.disconnect();
}, []);
return null;
}The whole chat API (history, threads, presence, typing, reactions, receipts
and attachments) is in the Chat section, and @livqeno/react has
chat hooks too. See React → Chat.
Calls and chat in one object
If a page has both, @livqeno/live gives you one object instead of two.
Pass both grants in:
import { createLivqeno } from '@livqeno/live';
const livqeno = createLivqeno({
token: rtc.token,
endpoint: rtc.endpoint,
iceServers: rtc.iceServers,
chatToken: chat.token,
chatApiUrl: chat.apiUrl,
});
const room = await livqeno.join(rtc.roomName);
await room.enableCamera();
await livqeno.chat!.connect({ room: 'general' });
// leave() ends the call but keeps chat; dispose() closes both.
await livqeno.leave();
await livqeno.dispose();Run it inside useEffect or an event handler, like everything on the
browser side. More in Livqeno Client.
Add live streaming
A live stream is a call and a chat set up for you in one step, with roles: a host sends camera and mic, viewers only watch. Which method your Route Handler calls decides the role — never a field in the request body — so check in your own code first that the caller really is the host.
pnpm add @livqeno/livenpm install @livqeno/liveyarn add @livqeno/livebun add @livqeno/live@livqeno/react's live-streaming components read from @livqeno/live, so it
needs to be installed alongside @livqeno/react even though this page never
imports from it directly.
Create the stream once, when the host schedules it — a Server Action or an admin Route Handler, not the page every viewer loads:
import { livqeno } from '@/lib/livqeno';
import { getCurrentUser } from '@/lib/auth';
export async function POST() {
const user = await getCurrentUser();
if (!user) return new Response('Unauthorized', { status: 401 });
const stream = await livqeno.liveStreams.create({
title: 'Friday Q&A',
hostIdentity: user.id,
visibility: 'PUBLIC',
});
await livqeno.liveStreams.start(stream.id); // CREATED → LIVE
return Response.json(stream);
}Then mint credentials whenever someone opens the stream page. addHost
always returns credentials that can send video and audio; createViewerToken
always returns watch-only credentials — no request parameter changes that:
import { livqeno } from '@/lib/livqeno';
import { getCurrentUser } from '@/lib/auth';
export async function POST(request: Request, { params }: { params: { streamId: string } }) {
const user = await getCurrentUser();
if (!user) return new Response('Unauthorized', { status: 401 });
const credentials = userIsHost(user, params.streamId) // your own check
? await livqeno.liveStreams.addHost(params.streamId, { identity: user.id })
: await livqeno.liveStreams.createViewerToken(params.streamId, user.id);
return Response.json(credentials); // { streamId, identity, role, rtc, chat, chatRootMessageId }
}<LivqenoLiveStream> connects to the call and chat the same way <LivqenoRoom>
and <LivqenoChat> do, so every call and chat hook already works inside it —
useCamera, useLocalParticipant, useRemoteParticipants, useMessages,
the lot. useLiveStreamRole is the one new hook, for showing a host's
controls to a host and not to a viewer:
'use client';
import { useEffect, useState } from 'react';
import {
LivqenoLiveStream,
ParticipantView,
useLiveStreamRole,
useCamera,
useLocalParticipant,
useRemoteParticipants,
type LiveStreamCredentials,
} from '@livqeno/react';
export function StreamClient({ streamId }: { streamId: string }) {
const [credentials, setCredentials] = useState<LiveStreamCredentials>();
useEffect(() => {
fetch(`/api/streams/${streamId}/join`, { method: 'POST' })
.then((res) => res.json())
.then(setCredentials);
}, [streamId]);
if (!credentials) return <p>Getting a token…</p>;
return (
<LivqenoLiveStream credentials={credentials} fallback={<p>Connecting…</p>}>
<Stage />
</LivqenoLiveStream>
);
}
function Stage() {
const { isHost } = useLiveStreamRole();
const camera = useCamera();
const local = useLocalParticipant();
const remote = useRemoteParticipants();
return (
<div>
{isHost && (
<button onClick={() => (camera.enabled ? camera.disable() : camera.enable())}>
Camera: {camera.enabled ? 'on' : 'off'}
</button>
)}
{local && <ParticipantView participant={local} />}
{remote.map((p) => (
<ParticipantView key={p.identity} participant={p} />
))}
</div>
);
}Roles, lifecycle, the stage (inviting a viewer up as a co-host) and HLS playback for large audiences are in Build a live stream and Live Streaming. Every live-stream hook is listed at React → Live streaming.
Without React
Using plain TypeScript in a Client Component? Use @livqeno/rtc directly,
inside useEffect, never at module scope:
'use client';
import { useEffect } from 'react';
import { createRTCClient, isBrowserSupported } from '@livqeno/rtc';
export function CallClient() {
useEffect(() => {
if (!isBrowserSupported()) return; // show a "use a newer browser" message instead
let client: ReturnType<typeof createRTCClient> | undefined;
fetch('/api/join-room', { method: 'POST' })
.then((res) => res.json())
.then(async (grant) => {
client = createRTCClient({
token: grant.token,
endpoint: grant.endpoint,
iceServers: grant.iceServers,
});
const room = await client.join(grant.roomName);
await room.enableCamera();
await room.enableMicrophone();
});
return () => void client?.leave();
}, []);
return null;
}The full call API is in RTC.
Server and client rules
| Where | Allowed | Not allowed |
|---|---|---|
Route Handlers, Server Actions, lib/livqeno.ts | @livqeno/server, LIVQENO_API_KEY | Browser packages |
| Server Components | Rendering a Client Component | Calling createRTCClient(), hooks |
Client Components ('use client') | @livqeno/react, /rtc, /chat, /client inside effects and handlers | Your API key, @livqeno/server |
Deploying
- Add
LIVQENO_API_KEYandLIVQENO_API_URLto your host's environment variables (for Production and Preview). Use aproductionkey in production. See Environments. - Add your site's address, and your preview URLs, under Settings →
Security → Allowed Origins in the dashboard. Requests from any other
origin fail with
ORIGIN_NOT_ALLOWED. - Before launch, go through the Production checklist.
Bundle size
@livqeno/rtc from a real build:
| File | Raw | Gzip |
|---|---|---|
dist/index.js (ESM) | 40.1 KB | 10.2 KB |
dist/index.cjs (CJS) | 40.7 KB | 10.2 KB |
That doesn't include the WebRTC client underneath, which npm install adds
for you (about 274 KB gzipped). It only loads in Client Components, so
Server Component pages don't pay for it.
Common mistakes
| Mistake | What you'll see | Fix |
|---|---|---|
NEXT_PUBLIC_LIVQENO_API_KEY | Your key is in the browser bundle. Anyone can use your whole project. | Rename it to LIVQENO_API_KEY and rotate the old key. |
Using <LivqenoRoom> or a hook in a file without 'use client' | Build error about hooks or missing browser APIs. | Move the call UI into a 'use client' file. |
Taking identity from the request body | Any visitor can join as any user. | Read the user from your session in the Route Handler. |
Leaving out iceServers | Calls work on Wi-Fi but fail on mobile data or office networks. | Pass the grant's iceServers to <LivqenoRoom>. |
| Reusing a grant for a second join | The second join is rejected. | Fetch a new grant, then remount with key={grant.token}. |
| Testing on a preview URL | ORIGIN_NOT_ALLOWED. | Add the preview origin under Allowed Origins. |