Статтю опубліковано англійською мовою.
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.
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.