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

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-sdk

Create an .env.local file for your API key:

ANAM_API_KEY=your_api_key_here

Never 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 ready
  • AnamEvent.MESSAGE_HISTORY_UPDATED - new message in the conversation
  • AnamEvent.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 dev

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