January 17, 2025
Building a basic NextJS app with the Anam SDK
The Anam JavaScript SDK lets you add conversational AI personas to web apps. By default, it captures audio from the user's microphone and streams back video of a talking avatar. In this cookbook we'll build a simple Next.js app that does exactly this.
The complete code is at examples/basic-nextjs.
What you'll build
A single-page Next.js app with a video player. Users click to start, then talk into their microphone. The persona responds with voice and video.
Prerequisites
- Node.js 18+
- An Anam account (sign up at lab.anam.ai)
- Your API key from the Anam Lab dashboard
Project setup
Create a Next.js app and install the SDK:
pnpm create next-app@latest my-anam-app
cd my-anam-app
pnpm add @anam-ai/js-sdkCreate an .env.local file for your API key:
ANAM_API_KEY=your_api_key_hereNever expose your API key in client-side code. We'll generate session tokens from a server-side API route.
Persona configuration
The persona config defines how your avatar looks and behaves. Put this in src/config/persona.ts:
// src/config/persona.ts
export const personaConfig = {
// Avatar - the visual character
avatarId: "edf6fdcb-acab-44b8-b974-ded72665ee26",
// Voice - how the persona sounds
voiceId: "6bfbe25a-979d-40f3-a92b-5394170af54b",
// LLM - the AI model powering conversations
llmId: "0934d97d-0c3a-4f33-91b0-5e136a0ef466",
// System prompt - defines personality and behavior
systemPrompt: `You are a friendly AI assistant. Keep your responses concise and conversational.`,
};You can browse avatars and voices at lab.anam.ai and swap in different IDs.
Session token API route
The Anam client needs a session token. Generate it server-side so your API key stays secret:
// src/app/api/session-token/route.ts
import { NextResponse } from "next/server";
import { personaConfig } from "@/config/persona";
export async function POST() {
const apiKey = process.env.ANAM_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "ANAM_API_KEY is not configured" },
{ status: 500 }
);
}
try {
const response = await fetch("https://api.anam.ai/v1/auth/session-token", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ personaConfig }),
});
if (!response.ok) {
const error = await response.text();
console.error("Anam API error:", error);
return NextResponse.json(
{ error: "Failed to get session token" },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json({ sessionToken: data.sessionToken });
} catch (error) {
console.error("Error fetching session token:", error);
return NextResponse.json(
{ error: "Failed to get session token" },
{ status: 500 }
);
}
}Building the persona player component
This component handles the video stream and connection lifecycle. We'll build it in pieces.
Start with imports:
// src/components/PersonaPlayer.tsx
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import {
createClient,
AnamEvent,
ConnectionClosedCode,
} from "@anam-ai/js-sdk";
import type { AnamClient, Message } from "@anam-ai/js-sdk";
type ConnectionState = "idle" | "connecting" | "connected" | "error";Fetching a session token
A wrapper to get tokens from our API route:
async function fetchSessionToken(): Promise<string> {
const response = await fetch("/api/session-token", { method: "POST" });
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to get session token");
}
const { sessionToken } = await response.json();
return sessionToken;
}Setting up event listeners
The SDK emits events for connection changes and new messages:
AnamEvent.CONNECTION_ESTABLISHED- connected and readyAnamEvent.MESSAGE_HISTORY_UPDATED- new message in the conversationAnamEvent.CONNECTION_CLOSED- connection ended, with a reason code
function setupEventListeners(
client: AnamClient,
handlers: {
onConnected: () => void;
onDisconnected: () => void;
onError: (message: string) => void;
onMessagesUpdated: (messages: Message[]) => void;
}
) {
client.addListener(AnamEvent.CONNECTION_ESTABLISHED, handlers.onConnected);
client.addListener(AnamEvent.MESSAGE_HISTORY_UPDATED, handlers.onMessagesUpdated);
client.addListener(AnamEvent.CONNECTION_CLOSED, (reason, details) => {
if (reason !== ConnectionClosedCode.NORMAL) {
handlers.onError(details || `Connection closed: ${reason}`);
} else {
handlers.onDisconnected();
}
});
}The component and session management
The component tracks connection state, errors, and messages. A ref holds the client instance for cleanup:
export function PersonaPlayer() {
const [connectionState, setConnectionState] = useState<ConnectionState>("idle");
const [error, setError] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const clientRef = useRef<AnamClient | null>(null);
const startSession = useCallback(async () => {
setConnectionState("connecting");
setError(null);
try {
const sessionToken = await fetchSessionToken();
const client = createClient(sessionToken);
clientRef.current = client;
setupEventListeners(client, {
onConnected: () => setConnectionState("connected"),
onDisconnected: () => setConnectionState("idle"),
onError: (message) => {
setError(message);
setConnectionState("error");
},
onMessagesUpdated: setMessages,
});
await client.streamToVideoElement("avatar-video");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start session");
setConnectionState("error");
}
}, []);
const stopSession = useCallback(() => {
if (clientRef.current) {
clientRef.current.stopStreaming();
clientRef.current = null;
}
setConnectionState("idle");
setMessages([]);
}, []);
// Clean up on unmount
useEffect(() => {
return () => {
if (clientRef.current) {
clientRef.current.stopStreaming();
}
};
}, []);startSession fetches a token, creates the client, sets up listeners, and calls streamToVideoElement() to start streaming. That handles all the WebRTC setup. Once it resolves, the persona greets the user and the microphone is active.
Rendering the video player
The video element ID must match what we passed to streamToVideoElement(). The 3:2 aspect ratio matches the 720x480 stream:
return (
<div className="flex flex-col gap-6 w-full max-w-4xl mx-auto">
<div className="relative aspect-[3/2] bg-black rounded-lg overflow-hidden">
<video
id="avatar-video"
autoPlay
playsInline
className="w-full h-full object-cover"
/>
</div>
</div>
);
}Add UI controls for each connection state:
{connectionState === "idle" && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900">
<button
onClick={startSession}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Start conversation
</button>
</div>
)}
{connectionState === "connecting" && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900">
<div className="text-white">Connecting...</div>
</div>
)}
{connectionState === "error" && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 gap-4">
<div className="text-red-400">{error}</div>
<button
onClick={startSession}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try again
</button>
</div>
)}
{connectionState === "connected" && (
<button
onClick={stopSession}
className="absolute top-4 right-4 px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
>
End session
</button>
)}Add a conversation history panel showing transcribed speech and responses:
{connectionState === "connected" && (
<div className="h-48 overflow-y-auto bg-white rounded-lg border p-4 space-y-3">
{messages.length === 0 ? (
<p className="text-gray-500 text-sm">
Start speaking to have a conversation...
</p>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`text-sm ${
msg.role === "user" ? "text-blue-700" : "text-gray-800"
}`}
>
<span className="font-medium">
{msg.role === "user" ? "You" : "Persona"}:
</span>{" "}
{msg.content}
</div>
))
)}
</div>
)}Adding the component to the page
Import and render the component:
// src/app/page.tsx
import { PersonaPlayer } from "@/components/PersonaPlayer";
export default function Home() {
return (
<main>
<h1>Anam Persona Demo</h1>
<p>Click to start a conversation. Speak using your microphone.</p>
<PersonaPlayer />
</main>
);
}Running the app
pnpm devOpen http://localhost:3000, click "Start conversation", and talk to your persona.
Try editing src/config/persona.ts to change the system prompt. Swap in different avatar and voice IDs from Anam Lab to see how they affect the experience.