# 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.

- Canonical: https://realtimeavatar.ai/docs/nextjs
- Updated: 2026-09-14

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](https://realtimeavatar.ai/downloads/nextjs-avatar-starter.zip) 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.

**Terminal**

```bash
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](https://realtimeavatar.ai/platform/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.

**Next.js**

```tsx
// 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](https://realtimeavatar.ai/docs/react) for the hook underneath it and for controlling the call yourself.

**Next.js**

```tsx
"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.

**Next.js**

```tsx
// 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](https://realtimeavatar.ai/docs/sessions) for retention, access and transcript correlation details.

## Troubleshooting the first call

| Symptom | Check |
| --- | --- |
| 404 on connect | Keep the catch-all `[...path]` directory and match the client's proxy URL. |
| Missing key or 503 in the starter | Set the server-only variable in `.env.local` and restart the development server. |
| 401 or 403 | Check API key scopes and your authorization callback. The starter refuses production calls until you implement application authorization. |
| 402 | Check the workspace's available credits, entitlement and active reservations. |
| Waiting in queue | Keep the queue state visible. Include queue time when measuring the user's startup experience. |
| No microphone audio | Use 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](https://realtimeavatar.ai/blog/how-to-benchmark-realtime-avatar-startup-and-turn-latency) before comparing those values with spoken-turn latency.

## Next

- [Authentication](https://realtimeavatar.ai/docs/authentication) — keys, scopes, and what `authorize` is really gating.
- [Calls](https://realtimeavatar.ai/docs/sessions) — everything the `session` policy can decide.

---

Realtime Avatar — realtime AI avatar API & SDK. Docs: https://realtimeavatar.ai/docs · Agent guide: https://realtimeavatar.ai/llms.txt
