Docs

Build a realtime AI avatar in Next.js

Build a working Next.js avatar app with a server-side session proxy, React video UI, a complete local starter, and troubleshooting.

Your API key must never reach a browser, so the browser talks to your app and your app talks to us. The Next.js App Router adapter handles that boundary.

Run the complete starter

Download the Next.js starter for a complete local app with a ready avatar, start and end controls, optional microphone, text messages, and downloadable startup observations. It pins Next.js 16.3.4, React 19.3.0 and realtime-avatar 0.16.0. Use Node.js 22 or newer.

curl -fLO https://realtimeavatar.ai/downloads/nextjs-avatar-starter.zip
unzip nextjs-avatar-starter.zip
cd nextjs-avatar-starter
npm ci
cp .env.example .env.local
# Set REALTIME_AVATAR_API_KEY in .env.local, then:
npm run dev

Get a session-capable key from API keys, then open http://127.0.0.1:3000. Click Start call, wait for live, and send a message. The example uses seed-rin-ashfall, so no avatar creation is needed. Each call uses credits and is capped at 60 seconds. Loading the page alone does not start a call.

The starter permits local development only. Before hosting it, replace its authorization callback with your own sign-in, balance and rate checks, and back ownsSession with session-to-user records. The SDK's default in-process ownership tracking fits a single local process; it does not span serverless instances. The snippets below show where your application policy belongs.

The server half

App Router, mounted at a catch-all so every operation reaches the handler. The [...path] segment is not decoration — without it the route only ever matches the mount path itself and answers 404 for connect, end, avatars and credits alike.

// app/api/realtime-avatar/[...path]/route.ts
import { createRealtimeAvatarRoute } from "realtime-avatar/nextjs";

export const { GET, POST } = createRealtimeAvatarRoute({
  apiKey: process.env.REALTIME_AVATAR_API_KEY!,

  // Who may do this. Return a Response to refuse, or nothing to allow.
  // "connect" is the only operation that costs money to start, so that is
  // where the wallet check belongs; the reads stay cheap.
  authorize: async ({ request, operation }) => {
    const user = await currentUser(request);
    if (!user) return new Response("Sign in", { status: 401 });
    if (operation === "connect" && !user.credits) {
      return Response.json({ code: "insufficient_credits" }, { status: 402 });
    }
  },

  // What the character knows. Decided HERE — the client sends none of it.
  session: async ({ avatarId }) => ({
    instructions: promptFor(avatarId),
  }),
});

Name the variable without NEXT_PUBLIC_. That prefix is what inlines a value into the client bundle, so a key wearing it ships to every visitor — the one mistake this whole split exists to prevent.

The client half

One component, pointed at the route you just mounted. It is the same component on every framework — see React for the hook underneath it and for controlling the call yourself.

"use client";
import { AvatarCall, createProxyClient } from "realtime-avatar/react";

const client = createProxyClient({ proxyUrl: "/api/realtime-avatar" });

export function Call({ avatarId }: { avatarId: string }) {
  return <AvatarCall client={client} avatarId={avatarId} />;
}

"use client" is required: the call holds a WebRTC room and browser media, none of which exist in a server component.

Record a call on the server

Recording is a server policy. Add recording to the session returned by the proxy; the browser cannot enable or disable it. RTA runs LiveKit Egress and returns a typed artifact after the call finishes. Use audio_video when you need one timeline for transcript and lip-sync analysis.

// app/api/realtime-avatar/[...path]/route.ts
export const { GET, POST } = createRealtimeAvatarRoute({
  apiKey: process.env.REALTIME_AVATAR_API_KEY!,
  session: async ({ avatarId }) => ({
    instructions: promptFor(avatarId),
    recording: "audio_video", // "off" | "audio" | "video" | "audio_video"
  }),
});

// server-only follow-up, after the call:
const artifact = await rta.getRecording(recordingId);
if (artifact.status === "ready") {
  const playback = await rta.getRecordingAccess(artifact.recordingId);
  // Keep recordingId; playback.url expires and can be renewed.
}

Recording reads require the recordings:read scope and stay tenant-scoped. Keep only recordingId in your database; never persist the temporary playback URL. See Calls for retention, access and transcript correlation details.

Troubleshooting the first call

SymptomCheck
404 on connectKeep the catch-all [...path] directory and match the client's proxy URL.
Missing key or 503 in the starterSet the server-only variable in .env.local and restart the development server.
401 or 403Check API key scopes and your authorization callback. The starter refuses production calls until you implement application authorization.
402Check the workspace's available credits, entitlement and active reservations.
Waiting in queueKeep the queue state visible. Include queue time when measuring the user's startup experience.
No microphone audioUse localhost or HTTPS and grant permission. Try a text message to test the rest of the path.

The starter's JSON distinguishes SDK readiness from the first displayed remote video frame. Read the benchmark methodology before comparing those values with spoken-turn latency.

Next

  • Authentication — keys, scopes, and what authorize is really gating.
  • Calls — everything the session policy can decide.