livqenoDocs

SDKsNext.js

Next.js

Add Livqeno video calls and chat to a Next.js App Router app. Mint tokens in a Route Handler, join from a Client Component.

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

  1. Install the server, browser and React packages.
  2. Put LIVQENO_API_KEY and LIVQENO_API_URL in .env.local.
  3. Add a Route Handler that mints a grant, and a Client Component that joins with it.
pnpm add @livqeno/server @livqeno/rtc @livqeno/react

Requirements

Minimum
Next.jsApp Router (13.4 or later)
React^18 or ^19
Node.js (server)20
BrowserAny 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

PackageRuns onUse it for
@livqeno/serverServer onlyCreating rooms and minting tokens with your API key
@livqeno/reactBrowser<LivqenoRoom> and hooks for calls, chat and live streams
@livqeno/rtcBrowserThe call client underneath @livqeno/react, or on its own without React
@livqeno/chatBrowserChat on its own
@livqeno/liveBrowserCalls 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:

.env.local
LIVQENO_API_KEY=rvk_dev_...
LIVQENO_API_URL=https://api.livqeno.com

Project structure

Project 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:

lib/livqeno.ts
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:

app/api/join-room/route.ts
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':

app/call/call-client.tsx
'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:

app/call/page.tsx
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/chat
app/api/chat-token/route.ts
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 });
 
  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:

app/chat/chat-client.tsx
'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:

app/meeting/meeting-client.tsx
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/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:

app/api/streams/route.ts
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:

app/api/streams/[streamId]/join/route.ts
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:

app/streams/[streamId]/stream-client.tsx
'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:

app/call/call-client.tsx
'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

WhereAllowedNot allowed
Route Handlers, Server Actions, lib/livqeno.ts@livqeno/server, LIVQENO_API_KEYBrowser packages
Server ComponentsRendering a Client ComponentCalling createRTCClient(), hooks
Client Components ('use client')@livqeno/react, /rtc, /chat, /client inside effects and handlersYour API key, @livqeno/server

Deploying

  1. Add LIVQENO_API_KEY and LIVQENO_API_URL to your host's environment variables (for Production and Preview). Use a production key in production. See Environments.
  2. 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.
  3. Before launch, go through the Production checklist.

Bundle size

@livqeno/rtc from a real build:

FileRawGzip
dist/index.js (ESM)40.1 KB10.2 KB
dist/index.cjs (CJS)40.7 KB10.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

MistakeWhat you'll seeFix
NEXT_PUBLIC_LIVQENO_API_KEYYour 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 bodyAny visitor can join as any user.Read the user from your session in the Route Handler.
Leaving out iceServersCalls 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 joinThe second join is rejected.Fetch a new grant, then remount with key={grant.token}.
Testing on a preview URLORIGIN_NOT_ALLOWED.Add the preview origin under Allowed Origins.

Next steps