How to add agent analytics to a Next.js app

Install the Amplitude AI SDK on the server side of a Next.js app, wrap an LLM route handler in an agent session, link it to the user's browser session, and flush before the function freezes. About 20 minutes, all copy-paste.

  • Next.js 15+
  • App Router
  • Amplitude AI SDK
  • 20 min

Chris Tam · Last updated 25 September 2026

Next.js is one of the most popular frameworks for building web apps. If you've shipped an AI feature in it, agent analytics shows you how good the answers are, what they cost, and which conversations go wrong.

In this tutorial, you'll add agent analytics to a Next.js route handler that calls a model, including streamed responses.

What you'll build

A POST /api/chat route handler that records [Agent] User Message and [Agent] AI Response for every turn under one [Agent] Session ID per conversation, with [Agent] Model Name, [Agent] Input Tokens, [Agent] Output Tokens, [Agent] Cost USD, and [Agent] Latency Ms filled in by the OpenAI wrapper, tied to the same user and browser session as your product analytics. By the end you'll see those events in your user's event stream and the conversation in agent analytics.

Prerequisites

  • A Next.js 15 or later App Router project with a server-side LLM call. The examples use OpenAI; the SDK also wraps Anthropic, Azure OpenAI, Gemini, Google Gen AI, Bedrock, and Mistral.
  • Browser analytics initialized with @amplitude/unified, as in How to add product analytics to a Next.js app. You need it only for step 5.
  • An Amplitude project with agent analytics enabled, and its project API key. In Amplitude, go to Settings > Projects > your project > General. Agent data can live in the same project as your product analytics, which is the simplest setup.
  • The View agent analytics Objects permission on your role, which you need to open agent analytics at all.

No Amplitude account yet? A free account is enough for this tutorial; the Free plan includes 5,000 agent sessions a month. Sign up for free to get your project API key.

1. Install the SDK

The Node path needs two packages: @amplitude/ai and the Node analytics client it sends through. Add openai if it isn't already a direct dependency, because the provider wrapper instruments the openai package itself. Add server-only so an accidental client import fails the build. For every option, see the agent analytics SDK reference.

Terminal
npm install @amplitude/ai @amplitude/analytics-node openai server-only

The docs name the variable AMPLITUDE_AI_API_KEY, and step 3 passes it to the constructor. It holds your standard project API key. Keep it server-side: no NEXT_PUBLIC_ prefix, so Next.js never inlines it into browser code.

.env.local
AMPLITUDE_AI_API_KEY=your-project-api-key
OPENAI_API_KEY=your-openai-key

Prefer to let a coding agent do the instrumentation? The docs ship a setup prompt: install the two packages, then tell your agent to follow node_modules/@amplitude/ai/amplitude-ai.md. The steps below are what it should produce on Next.js.

2. Keep it out of the bundle and on the Node runtime

Tell Next.js not to bundle the SDK into the server build. The docs require this for the App Router.

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  serverExternalPackages: ['@amplitude/ai'],
};

export default nextConfig;

Why. serverExternalPackages makes Next.js load the package with Node's own require at runtime instead of compiling it into the route bundle. On Next.js 13 and 14 the same option is experimental.serverComponentsExternalPackages, and next.config.ts itself needs 15; use next.config.mjs there.

The route handler in step 4 also pins export const runtime = 'nodejs'. The SDK depends on node:async_hooks, node:module, and node:crypto, and the docs list edge runtimes as unsupported. Don't set runtime = 'edge' on any route that imports it.

3. Initialize once in a server-only module

Create one module that constructs the client, the wrapped OpenAI client, and the agent at module scope. Route handlers import from it and never construct their own.

lib/amplitude-ai.ts
import 'server-only';
import { AmplitudeAI, AIConfig, OpenAI } from '@amplitude/ai';

export const ai = new AmplitudeAI({
  apiKey: process.env.AMPLITUDE_AI_API_KEY!,
  config: new AIConfig({
    contentMode: 'full', // prompt and response text, with PII redaction
    redactPii: true,
    debug: process.env.NODE_ENV === 'development',
  }),
});

// Use this instead of `new OpenAI()` from 'openai'. It forwards every call
// and records model, tokens, latency, and cost on [Agent] AI Response.
export const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY!,
  amplitude: ai,
});

export const supportAgent = ai.agent('support-chat', {
  description: 'In-app support assistant',
});

Why. ai.agent(...) inside a request handler mints a new [Agent] Agent ID on every turn and breaks session grouping, so the agent lives here. The file name isn't lib/amplitude.ts because that's the browser SDK module on the sibling Next.js page, and server-only makes the build fail if a client component ever imports this one.

contentMode: 'full' is the default and sends prompt and response text after scrubbing emails, phone numbers, SSNs, card numbers, and IP addresses. If your conversations are regulated, use 'metadata_only': you still get tokens, latency, model, cost, and session grouping, with no text. debug prints each tracked event to stderr, which is your terminal under npm run dev.

4. Wrap the route handler in a session

Open one session per request, keyed on the conversation's ID, and make the LLM call inside run(). The wrapper sees the call happen inside the session and records the AI response against it.

app/api/chat/route.ts
import { ai, openai, supportAgent } from '@/lib/amplitude-ai';
import { getCurrentUserId } from '@/lib/auth';

export const runtime = 'nodejs';

type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };

export async function POST(req: Request) {
  const { conversationId, messages } = (await req.json()) as {
    conversationId: string;
    messages: ChatMessage[];
  };

  // The same string the browser passes to setUserId. Omit it when signed out;
  // never send a placeholder like "anonymous".
  const userId = (await getCurrentUserId()) ?? undefined;

  // Sent by the chat component in step 5, from the Browser SDK.
  const browserSessionId = req.headers.get('x-amplitude-session-id') ?? undefined;
  const deviceId = req.headers.get('x-amplitude-device-id') ?? undefined;

  const reply = await supportAgent
    .session({ userId, deviceId, browserSessionId, sessionId: conversationId })
    .run(async (s) => {
      s.trackUserMessage(messages[messages.length - 1].content);

      const completion = await openai.chat.completions.create({
        model: 'gpt-4o-mini',
        messages,
      });

      return completion.choices[0].message.content ?? '';
    });

  // Send buffered events before the serverless function can freeze.
  await ai.flush();

  return Response.json({ reply });
}

Why. On Vercel and other detected serverless platforms, session.run() already flushes before it resolves, so the explicit await ai.flush() looks redundant. Keep it. The docs' Next.js guidance calls for it, it costs nothing when the buffer is empty, and it covers hosts the SDK doesn't detect. Without a flush on those hosts, the function freezes with events still in memory and nothing ever arrives.

Reuse conversationId across every turn of the same conversation, and never across users. Turns stitch into one session because they share the ID. When the same user starts a new conversation, send a new ID. The session closes after 30 minutes without activity by default, and about then Amplitude's enrichment evaluates it and emits [Agent] Session Record with task completion, response quality, user friction, and the session's total cost.

Use the canonical model ID, gpt-4o-mini, not a gateway alias. Cost comes from token counts multiplied against a public model-price catalog, and a name it can't price leaves the whole session's cost empty.

5. Send the browser IDs from the chat component

To tie a conversation to the product session around it, and to get a session replay link on it, the route handler needs the Browser SDK's session ID and device ID. Read them in the client and send them as headers.

components/SupportChat.tsx
'use client';

import { useState } from 'react';
import { amplitude } from '@/lib/amplitude';

type ChatMessage = { role: 'user' | 'assistant'; content: string };

export function SupportChat() {
  const [conversationId] = useState(() => crypto.randomUUID());
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState('');

  async function send() {
    const next: ChatMessage[] = [...messages, { role: 'user', content: input }];
    setMessages(next);
    setInput('');

    const headers: Record<string, string> = { 'Content-Type': 'application/json' };
    const sessionId = amplitude.getSessionId();
    const deviceId = amplitude.getDeviceId();
    if (sessionId) headers['x-amplitude-session-id'] = String(sessionId);
    if (deviceId) headers['x-amplitude-device-id'] = deviceId;

    const res = await fetch('/api/chat', {
      method: 'POST',
      headers,
      body: JSON.stringify({ conversationId, messages: next }),
    });
    const { reply } = (await res.json()) as { reply: string };
    setMessages([...next, { role: 'assistant', content: reply }]);
  }

  return (
    <div>
      {messages.map((m, i) => (
        <p key={i}>
          <strong>{m.role}:</strong> {m.content}
        </p>
      ))}
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={send} disabled={!input}>
        Send
      </button>
    </div>
  );
}

Why. The agent session and the browser session are different things. The agent session is the conversation, and your app owns its ID. The browser session is the visit that powers session replay, and the Browser SDK owns it. Forwarding the device ID also keeps a signed-out visitor from splitting into two users, since a backend that generates its own device ID per request breaks the merge.

If your frontend runs session replay, the [Amplitude] Session Replay ID lands on the session's [Agent] events, and the sessions pane shows a replay link.

MCP. Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing taxonomy before it adds anything, so the Plan Upgraded event your server action already sends and the [Agent] events from this page end up in charts together instead of next to a duplicate planUpgraded. Set up the MCP server.

6. Keep streaming sessions open until the stream ends

If your route streams, returning the stream from run() ends the session before the model finishes, and the [Agent] AI Response event is dropped. Keep the session open by draining a copy of the stream inside it:

app/api/chat/stream/route.ts
import { openai, supportAgent } from '@/lib/amplitude-ai';
import { getCurrentUserId } from '@/lib/auth';

export const runtime = 'nodejs';

type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };

export async function POST(req: Request) {
  const { conversationId, messages } = (await req.json()) as {
    conversationId: string;
    messages: ChatMessage[];
  };
  const userId = (await getCurrentUserId()) ?? undefined;
  const browserSessionId = req.headers.get('x-amplitude-session-id') ?? undefined;
  const deviceId = req.headers.get('x-amplitude-device-id') ?? undefined;

  return supportAgent
    .session({ userId, deviceId, browserSessionId, sessionId: conversationId })
    .run(async (s) => {
      s.trackUserMessage(messages[messages.length - 1].content);

      const stream = await openai.chat.completions.create({
        model: 'gpt-4o-mini',
        messages,
        stream: true,
      });

      // Drain one copy inside the session so it stays open until the
      // stream completes; send the other copy to the client.
      const [passthrough, forClient] = stream.toReadableStream().tee();
      const reader = passthrough.getReader();
      (async () => {
        while (!(await reader.read()).done) {}
      })();

      return new Response(forClient);
    });
}

Using the Vercel AI SDK? The provider wrappers instrument the openai package, not the @ai-sdk/openai provider. The docs say to either add openai as a direct dependency or fall back to patch(), and to flush in streamText's onFinish callback:

app/api/chat/route.ts (Vercel AI SDK, inside supportAgent.session(...).run)
const result = await streamText({
  model: openai('gpt-4o'),
  messages,
  onFinish: async () => {
    await ai.flush();
  },
});

Here openai is the @ai-sdk/openai provider, not the wrapped client from lib/amplitude-ai.ts. patch() records calls only inside an active session context; a patched call outside run() is dropped silently.

Verify it worked

Run npm run dev, open the page with SupportChat, send two messages in the same conversation, then check in this order:

  1. Terminal. With debug on in development, every tracked event prints to stderr in the next dev terminal with its model, tokens, cost, and latency. Nothing printing means an instrumentation problem, usually an LLM call outside run(). You can also run npx amplitude-ai doctor after one real conversation.
  2. Network tab. Filter for /api/chat. The request headers should include x-amplitude-session-id and x-amplitude-device-id. The server's own upload to Amplitude doesn't show here, since it leaves from Node.
  3. Ingestion Debugger. In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your server's uploads. It shows counts, not individual events.
  4. User lookup. Go to Users > User Profiles and search your user ID or device ID. Within ten seconds to a minute the event stream shows [Agent] User Message and [Agent] AI Response for each turn. Open an AI Response and confirm [Agent] Model Name, [Agent] Provider, [Agent] Input Tokens, [Agent] Output Tokens, [Agent] Cost USD, [Agent] Latency Ms, [Agent] Session ID (your conversationId), and [Agent] Agent ID (support-chat) are all set.
  5. Agent analytics. Open agent analytics in the left nav. The conversation appears as one session. Quality signals arrive on [Agent] Session Record after the session closes, about 30 minutes after the last message.

Troubleshooting

Nothing prints in the terminal and no events arrive

The LLM call ran outside a session context, or used an unwrapped client. Import openai from lib/amplitude-ai.ts, not from 'openai', and make the call inside run(). patch() drops calls outside a session with no error.

Events print locally but never arrive from the deployed app

The function froze before the buffer shipped, or the key is wrong. Confirm await ai.flush() runs before the handler returns, and that AMPLITUDE_AI_API_KEY is set in your host's environment, not only in .env.local.

Build fails with a node:async_hooks or module resolution error

The SDK is being bundled, or a route that imports it runs on the edge. Add @amplitude/ai to serverExternalPackages, set runtime = 'nodejs', and check that no client component imports lib/amplitude-ai.ts.

Every turn shows up as a separate session or a new agent

ai.agent() is being called inside the handler, or the client sends a new conversationId per message. Keep the agent at module scope and hold the ID in state for the life of the conversation.

Streamed replies have a User Message but no AI Response

The session closed before the stream finished. Use the tee() pattern from step 6 so the session stays open until the stream completes.

[Agent] Cost USD is empty

The model name isn't one the price catalog knows, such as a gateway alias or a fine-tuned ft: model. Pass the canonical provider model ID, or pass the cost explicitly for that call.

Full code reference

Every file from this tutorial in one block, the version to hand to an agent. The streaming route from step 6 is optional; use it instead of app/api/chat/route.ts when you stream.

6 files, 149 lines
// -- .env.local --
AMPLITUDE_AI_API_KEY=your-project-api-key
OPENAI_API_KEY=your-openai-key

// -- next.config.ts --
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  serverExternalPackages: ['@amplitude/ai'],
};

export default nextConfig;

// -- lib/amplitude-ai.ts --
import 'server-only';
import { AmplitudeAI, AIConfig, OpenAI } from '@amplitude/ai';

export const ai = new AmplitudeAI({
  apiKey: process.env.AMPLITUDE_AI_API_KEY!,
  config: new AIConfig({
    contentMode: 'full',
    redactPii: true,
    debug: process.env.NODE_ENV === 'development',
  }),
});

export const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY!,
  amplitude: ai,
});

export const supportAgent = ai.agent('support-chat', {
  description: 'In-app support assistant',
});

// -- app/api/chat/route.ts --
import { ai, openai, supportAgent } from '@/lib/amplitude-ai';
import { getCurrentUserId } from '@/lib/auth';

export const runtime = 'nodejs';

type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };

export async function POST(req: Request) {
  const { conversationId, messages } = (await req.json()) as {
    conversationId: string;
    messages: ChatMessage[];
  };
  const userId = (await getCurrentUserId()) ?? undefined;
  const browserSessionId = req.headers.get('x-amplitude-session-id') ?? undefined;
  const deviceId = req.headers.get('x-amplitude-device-id') ?? undefined;

  const reply = await supportAgent
    .session({ userId, deviceId, browserSessionId, sessionId: conversationId })
    .run(async (s) => {
      s.trackUserMessage(messages[messages.length - 1].content);
      const completion = await openai.chat.completions.create({
        model: 'gpt-4o-mini',
        messages,
      });
      return completion.choices[0].message.content ?? '';
    });

  await ai.flush();
  return Response.json({ reply });
}

// -- components/SupportChat.tsx --
'use client';

import { useState } from 'react';
import { amplitude } from '@/lib/amplitude';

type ChatMessage = { role: 'user' | 'assistant'; content: string };

export function SupportChat() {
  const [conversationId] = useState(() => crypto.randomUUID());
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState('');

  async function send() {
    const next: ChatMessage[] = [...messages, { role: 'user', content: input }];
    setMessages(next);
    setInput('');

    const headers: Record<string, string> = { 'Content-Type': 'application/json' };
    const sessionId = amplitude.getSessionId();
    const deviceId = amplitude.getDeviceId();
    if (sessionId) headers['x-amplitude-session-id'] = String(sessionId);
    if (deviceId) headers['x-amplitude-device-id'] = deviceId;

    const res = await fetch('/api/chat', {
      method: 'POST',
      headers,
      body: JSON.stringify({ conversationId, messages: next }),
    });
    const { reply } = (await res.json()) as { reply: string };
    setMessages([...next, { role: 'assistant', content: reply }]);
  }

  return (
    <div>
      {messages.map((m, i) => (
        <p key={i}>
          <strong>{m.role}:</strong> {m.content}
        </p>
      ))}
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={send} disabled={!input}>
        Send
      </button>
    </div>
  );
}

// -- app/api/chat/stream/route.ts (optional, streaming) --
import { openai, supportAgent } from '@/lib/amplitude-ai';
import { getCurrentUserId } from '@/lib/auth';

export const runtime = 'nodejs';

type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };

export async function POST(req: Request) {
  const { conversationId, messages } = (await req.json()) as {
    conversationId: string;
    messages: ChatMessage[];
  };
  const userId = (await getCurrentUserId()) ?? undefined;
  const browserSessionId = req.headers.get('x-amplitude-session-id') ?? undefined;
  const deviceId = req.headers.get('x-amplitude-device-id') ?? undefined;

  return supportAgent
    .session({ userId, deviceId, browserSessionId, sessionId: conversationId })
    .run(async (s) => {
      s.trackUserMessage(messages[messages.length - 1].content);
      const stream = await openai.chat.completions.create({
        model: 'gpt-4o-mini',
        messages,
        stream: true,
      });
      const [passthrough, forClient] = stream.toReadableStream().tee();
      const reader = passthrough.getReader();
      (async () => {
        while (!(await reader.read()).done) {}
      })();
      return new Response(forClient);
    });
}

Hand it to your agent

Prefer to have Cursor, Claude Code, or Codex CLI do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt
Add Amplitude Agent Analytics to this Next.js App Router app by following
https://amplitude.com/build/nextjs-agent-analytics exactly. Install
@amplitude/ai, @amplitude/analytics-node, openai, and server-only. Add
@amplitude/ai to serverExternalPackages in next.config. Create
lib/amplitude-ai.ts (import 'server-only') that constructs AmplitudeAI from
process.env.AMPLITUDE_AI_API_KEY, the wrapped OpenAI client, and the agent,
all at module scope. Never import it from a client component, never
construct ai.agent() inside a handler, and never set runtime = 'edge' on
a route that uses it. In each LLM route handler, set runtime = 'nodejs',
open agent.session({ userId, deviceId, browserSessionId, sessionId }) with
the conversation ID as sessionId, make the LLM call inside run(), and
await ai.flush() before returning. For streaming, keep the session open
until the stream is consumed. Forward the Browser SDK's getSessionId() and
getDeviceId() as x-amplitude-session-id and x-amplitude-device-id headers.
Never pass a placeholder userId; omit it when signed out. Don't hardcode
keys and don't prefix them with NEXT_PUBLIC_. Report every file you
changed.

What this unlocks

  • Session replay opens the visit where [Agent] AI Response fired, through the [Amplitude] Session Replay ID your forwarded browser IDs put on the session.
  • Funnels in product analytics can run from [Agent] User Message to Plan Upgraded, because both carry the same user ID.
  • Feature Experiment can test gpt-4o-mini against another model with [Agent] Cost USD and [Agent] Response Quality as metrics.
  • Guides and surveys can reach users whose [Agent] Session Record shows [Agent] Has User Friction true.

One integration; everything else reads from the same events.

Next steps

Amplitude is the leading all-in-one analytics platform for building better products. Product and engineering teams use Amplitude to connect product analytics and web analytics with session replay, heatmaps, experimentation, feature flags, and guides and surveys in one unified workflow, with AI agents working across all of it. Learn how Amplitude is building self-improving products with Wave, helping teams turn product data into PRs.

Ship the instrumentation, then read the data

Once events are flowing, Amplitude turns them into funnels, retention curves, and session replays without any further setup.