Home / Articles / Building Multi-Step AI Agent UIs with Next.js and the AI SDK

This article is published in English.

Next.jsAI SDKTypeScriptLLMGenerative UIVercel

Building Multi-Step AI Agent UIs with Next.js and the AI SDK

Learn how to architect a production-ready AI agent interface using typed tools, multi-step loops, and streaming generative UI components in Next.js.

1729 words

Most tutorials covering AI systems stop right at the point where things start to matter. You write a small script, send a prompt to a model, and watch tokens print out in a terminal — and that already feels like progress.

The real difficulty shows up once you try to turn that into an actual product.

That's when it becomes clear that a business user has no interest in a raw block of markdown text. They expect interactive tables of data, a confirmation step before any destructive action runs, and smooth streaming output that doesn't cause the page to jump around.

What they actually need is a model that can coordinate several tool calls in sequence, while the interface renders properly typed React components for each stage of that process.

Designing an interface for an agent means treating the model as an active execution engine rather than a simple text generator. Below is a blueprint for building a production-grade agent interface in Next.js with the Vercel AI SDK — one that supports multi-step tool invocation, strongly typed payloads, and UI elements generated dynamically as the response streams in.

The Architecture: Moving Beyond Simple Text Streams

A robust agent pipeline looks nothing like a basic chatbot:

[ Client: useChat() ]
       │  ▲
       │  │  Data Stream (Tokens + Typed Tool Invocations)
       ▼  │
[ Next.js Route Handler: streamText() ]
       │  ▲
       │  │  Model generates tool arguments
       ▼  │
[ Tool Execution (e.g. DB Query / Stripe API) ]
       │
       └──> (Loop back to LLM if more steps are required)
  • Stateful transport: the client sends user input to a route handler running on the Edge or Node runtime.
  • Server-side tool execution: the model determines whether it already has enough information to respond, or whether it must call a predefined tool, validated through a Zod schema, to fetch external data.
  • Multi-step agent loops: once a tool finishes running on the server, its result is automatically passed back into the model — governed by step limits configured in the SDK — until it produces a final answer in natural language.
  • Typed generative UI: rather than displaying raw JSON, the client picks out typed tool segments from the stream and renders dedicated, interactive React components for them.

1. Defining the Agent API with Typed Tools

Start by building the API route with the App Router. The goal is an agent that can pull live account analytics and kick off transactional actions, such as generating an invoice.

Install the required packages:

npm install ai @ai-sdk/openai @ai-sdk/react zod

Then define the route handler inside app/api/agent/route.ts:

import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';

// Prevent function timeouts on long agent loops
export const maxDuration = 60;

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai('gpt-4o'),
    messages,
    // System instructions establish agent personality and boundaries
    system: `You are an internal operations assistant.
      Use the available tools to answer queries and perform operations.
      Always confirm parameters before invoking actions that write data.`,

    // Allow the model to execute a tool, inspect output, and take subsequent steps
    maxSteps: 5,

    tools: {
      getAccountMetrics: tool({
        description: 'Fetch real-time metrics for a customer account',
        parameters: z.object({
          accountId: z.string().describe('The alphanumeric account identifier'),
          timeframe: z.enum(['7d', '30d', '90d']),
        }),
        execute: async ({ accountId, timeframe }) => {
          // Replace with real database or external API call
          await new Promise((r) => setTimeout(r, 800)); // Simulating latency

          return {
            accountId,
            timeframe,
            revenue: 24500,
            activeUsers: 342,
            churnRisk: 'low',
          };
        },
      }),

      generateInvoiceDraft: tool({
        description: 'Prepare an invoice draft for customer review',
        parameters: z.object({
          recipientEmail: z.string().email(),
          amount: z.number().positive(),
          currency: z.string().default('USD'),
        }),
        execute: async ({ recipientEmail, amount, currency }) => {
          // Simulating invoice creation
          return {
            invoiceId: `inv_${Math.random().toString(36).substring(2, 9)}`,
            status: 'draft',
            recipientEmail,
            amount,
            currency,
            createdAt: new Date().toISOString(),
          };
        },
      }),
    },
  });

  return result.toDataStreamResponse();
}

Why maxSteps Matters

Older approaches to multi-step agent reasoning relied on clunky while loops, hand-rolled edits to the message array, and separate round trips to the LLM for each step.

Setting maxSteps: 5 lets the SDK take over that recursive tool-resolution cycle on the server, streaming every intermediate state straight through to the client as it happens.

2. Building the Client: Streaming Typed Component Islands

On the frontend, contemporary agent interfaces break each incoming response into parts instead of treating it as one text blob. A single response might mix plain text segments, tool calls still waiting to resolve, and tool results that have already completed.

Set up the chat container inside app/page.tsx:

'use client';

import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import { MetricsCard } from '@/components/tools/MetricsCard';
import { InvoiceCard } from '@/components/tools/InvoiceCard';

export default function AgentConsole() {
  const [input, setInput] = useState('');
  const { messages, append, status } = useChat({
    api: '/api/agent',
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim()) return;
    append({ role: 'user', content: input });
    setInput('');
  };

  const isLoading = status === 'streaming' || status === 'submitted';

  return (
    <main className="max-w-4xl mx-auto py-10 px-4 flex flex-col h-screen">
      <header className="border-b pb-4 mb-6">
        <h1 className="text-xl font-semibold tracking-tight">Operations Intelligence Agent</h1>
        <p className="text-sm text-neutral-500">Autonomous workflow orchestration and metrics exploration.</p>
      </header>

      {/* Message Feed */}
      <div className="flex-1 overflow-y-auto space-y-6 pr-2">
        {messages.map((m) => (
          <div
            key={m.id}
            className={`flex flex-col ${
              m.role === 'user' ? 'items-end' : 'items-start'
            }`}
          >
            <div
              className={`rounded-2xl px-4 py-3 max-w-[85%] text-sm ${
                m.role === 'user'
                  ? 'bg-blue-600 text-white'
                  : 'bg-neutral-100 text-neutral-900 border border-neutral-200'
              }`}
            >
              {/* Handle granular message parts */}
              {m.parts?.map((part, index) => {
                if (part.type === 'text') {
                  return <p key={index} className="whitespace-pre-wrap">{part.text}</p>;
                }

                // Handle tool rendering based on typed part identifier
                if (part.type === 'tool-invocation') {
                  const { toolName, state, args } = part.toolInvocation;

                  if (state !== 'result') {
                    return (
                      <div key={index} className="my-2 py-2 px-3 bg-neutral-50 rounded border border-dashed border-neutral-300 text-xs text-neutral-500 animate-pulse">
                        Executing tool: <code className="font-mono">{toolName}</code> with {JSON.stringify(args)}...
                      </div>
                    );
                  }

                  const { result } = part.toolInvocation;

                  // Render specialized components for each tool
                  if (toolName === 'getAccountMetrics') {
                    return <MetricsCard key={index} data={result} />;
                  }

                  if (toolName === 'generateInvoiceDraft') {
                    return <InvoiceCard key={index} data={result} />;
                  }
                }

                return null;
              }) ?? m.content}
            </div>
          </div>
        ))}
      </div>

      {/* Input Form */}
      <form onSubmit={handleSubmit} className="mt-4 flex gap-2">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask for metrics or draft an invoice..."
          disabled={isLoading}
          className="flex-1 rounded-lg border border-neutral-300 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50"
        />
        <button
          type="submit"
          disabled={isLoading}
          className="bg-neutral-900 text-white rounded-lg px-5 py-2 text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 transition"
        >
          {isLoading ? 'Thinking...' : 'Send'}
        </button>
      </form>
    </main>
  );
}

3. Rendering Generative UI Components

Instead of dumping raw JSON, you map schema output straight onto presentation components.

Here is components/tools/MetricsCard.tsx:

interface MetricsCardProps {
  data: {
    accountId: string;
    timeframe: string;
    revenue: number;
    activeUsers: number;
    churnRisk: string;
  };
}

export function MetricsCard({ data }: MetricsCardProps) {
  return (
    <div className="my-3 bg-white border border-neutral-200 rounded-xl p-4 shadow-sm text-neutral-800">
      <div className="flex items-center justify-between pb-2 border-b border-neutral-100 mb-3">
        <span className="font-mono text-xs text-neutral-400">ID: {data.accountId}</span>
        <span className="bg-emerald-50 text-emerald-700 text-xs px-2 py-0.5 rounded-full font-medium">
          Window: {data.timeframe}
        </span>
      </div>
      <div className="grid grid-cols-3 gap-4 text-center">
        <div>
          <p className="text-xs text-neutral-400 uppercase font-semibold">Revenue</p>
          <p className="text-lg font-bold">${data.revenue.toLocaleString()}</p>
        </div>
        <div>
          <p className="text-xs text-neutral-400 uppercase font-semibold">Users</p>
          <p className="text-lg font-bold">{data.activeUsers}</p>
        </div>
        <div>
          <p className="text-xs text-neutral-400 uppercase font-semibold">Churn Risk</p>
          <p className="text-lg font-bold capitalize text-emerald-600">{data.churnRisk}</p>
        </div>
      </div>
    </div>
  );
}

And components/tools/InvoiceCard.tsx:

interface InvoiceCardProps {
  data: {
    invoiceId: string;
    status: string;
    recipientEmail: string;
    amount: number;
    currency: string;
    createdAt: string;
  };
}

export function InvoiceCard({ data }: InvoiceCardProps) {
  return (
    <div className="my-3 bg-white border border-neutral-200 rounded-xl p-4 shadow-sm text-neutral-800">
      <div className="flex justify-between items-center mb-2">
        <h4 className="text-xs uppercase font-bold tracking-wider text-neutral-400">Invoice Draft Created</h4>
        <span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded font-mono font-medium">
          {data.status}
        </span>
      </div>
      <p className="text-xl font-bold font-mono my-1">
        ${data.amount} <span className="text-xs font-sans text-neutral-400">{data.currency}</span>
      </p>
      <p className="text-xs text-neutral-500">Recipient: <span className="text-neutral-700">{data.recipientEmail}</span></p>
      <div className="mt-3 flex gap-2">
        <button
          onClick={() => alert(`Invoice ${data.invoiceId} sent!`)}
          className="w-full bg-neutral-900 hover:bg-neutral-800 text-white text-xs font-semibold py-1.5 rounded transition"
        >
          Approve & Send
        </button>
      </div>
    </div>
  );
}

4. Hard-Earned Production Lessons

Taking an interface like this from localhost to a live deployment tends to surface edge cases fast. Keep the following safeguards in mind.

1. Scope Server Timeouts on Vercel

Agent loops that chain several tool calls together can easily exceed the default timeout window for serverless functions (typically 10 to 15 seconds). Make sure to declare an explicit duration setting in your App Router route:

export const maxDuration = 60; // Set according to your deployment plan limits

2. Tool Execution Idempotency

When a model misreads its own output or re-runs a step due to unclear instructions, it may end up invoking a mutation tool more than once. Any write-side tool, such as one that charges a payment method or creates a new account record, should require an idempotency key supplied either from the prompt itself or derived from the session identifier.

3. Human-In-The-Loop Confirmation

An agent should never be allowed to autonomously carry out irreversible operations, things like deleting records, sending real emails, or moving money.

  • Have the agent produce a draft state instead, marked with something like status: 'pending_approval'.
  • Show an interactive card that includes a manual confirmation button.
  • Require the user to explicitly approve and trigger the action through a standard Next.js Server Action.

The New Standard for AI Interfaces

Plain chat-completion boxes are becoming a thing of the past. Today's users aren't looking for a disembodied conversational partner; they want tools that cut down on workflow friction while leaving them firmly in charge.

By pairing Next.js App Router streaming with the AI SDK's typed tool runtime, you turn language models into dependable functional building blocks rather than unpredictable text generators.

The result is a system that combines the predictability of strongly typed TypeScript components with the flexible reasoning of foundation models, which is precisely the balance enterprise software needs.