Build AI-Powered Next.js Apps with Vercel AI SDK: Streaming, Tool Calling, and Structured Output
Build AI-powered Next.js apps with Vercel AI SDK v6. Step-by-step tutorial covering streaming chat with useChat, structured output with Zod, tool calling, AI agents, and Server Actions — with complete App Router code examples.

Let's be honest — adding AI features to a web app used to be a pain. You'd wrestle with raw API calls, manually manage streaming responses, and write fragile parsing logic for every single provider. The Vercel AI SDK changed that completely. Now at version 6, it gives you a unified TypeScript toolkit for streaming text, generating structured data, calling tools, and building full AI agents — all with first-class Next.js App Router support.
In this tutorial, we're building a complete AI-powered Next.js app from scratch. We'll start with a streaming chat interface, add structured output using Zod validation, implement tool calling so the AI can fetch real data, and finally create a reusable AI agent. Every example uses AI SDK v6 with Server Actions — the modern approach that replaces the older API route pattern.
Prerequisites
- Node.js 18.18 or later
- A Next.js 16+ project (or willingness to create one)
- An API key from at least one AI provider (OpenAI, Anthropic, Google, etc.)
- Basic familiarity with React Server Components and Server Actions
Project Setup and Installation
First things first — create a fresh Next.js project and install the AI SDK packages. The SDK is modular, so you install the core package and only the provider adapters you actually need.
npx create-next-app@latest my-ai-app --app --typescript --tailwind
cd my-ai-app
# Core AI SDK + React hooks + OpenAI provider
npm install ai @ai-sdk/react @ai-sdk/openai
# Optional: add more providers
npm install @ai-sdk/anthropic @ai-sdk/google
Create a .env.local file in your project root with your API key:
# .env.local
OPENAI_API_KEY=sk-your-key-here
# Optional additional providers
# ANTHROPIC_API_KEY=sk-ant-your-key-here
# GOOGLE_GENERATIVE_AI_API_KEY=your-key-here
The AI SDK has two layers. AI SDK Core runs on the server and handles model calls, streaming, tool execution, and structured output. AI SDK UI provides React hooks like useChat and useCompletion that manage client-side state for chat interfaces.
Together, they let you build AI features with surprisingly little boilerplate.
Build a Streaming Chat Interface
The most common AI feature is a chat interface with streaming responses. And honestly, AI SDK v6 makes this almost too easy with the useChat hook on the client and a route handler on the server.
Create the API Route Handler
Here's a route handler that receives messages and streams the AI response back:
// app/api/chat/route.ts
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
system: "You are a helpful assistant. Be concise and accurate.",
messages,
});
return result.toDataStreamResponse();
}
The streamText function connects to the model and starts generating. Then toDataStreamResponse() converts it into a format the useChat hook understands on the client side. Pretty straightforward.
Build the Chat UI Component
Now create a client component that uses useChat to manage the entire conversation lifecycle:
// app/chat/page.tsx
"use client";
import { useChat } from "@ai-sdk/react";
export default function ChatPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
useChat();
return (
<div className="mx-auto max-w-2xl p-4">
<h1 className="mb-6 text-2xl font-bold">AI Chat</h1>
<div className="mb-4 space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`rounded-lg p-3 ${
message.role === "user"
? "ml-auto max-w-xs bg-blue-600 text-white"
: "mr-auto max-w-md bg-gray-100 text-gray-900"
}`}
>
{message.parts.map((part, i) => {
if (part.type === "text") {
return <p key={i}>{part.text}</p>;
}
return null;
})}
</div>
))}
</div>
{error && (
<p className="mb-2 text-sm text-red-500">Error: {error.message}</p>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Type a message..."
className="flex-1 rounded-lg border p-2"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading}
className="rounded-lg bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isLoading ? "Thinking..." : "Send"}
</button>
</form>
</div>
);
}
That's it. The useChat hook handles everything — storing conversation history, sending new messages to /api/chat, streaming the response back, and updating the UI token by token. You get real-time streaming with about 30 lines of code. I was genuinely surprised how little wiring is needed the first time I set this up.
Server Actions: The Modern Alternative to API Routes
AI SDK v6 introduced a pretty big architectural shift: you can now use Server Actions instead of API routes for AI inference. This means no separate /api/chat endpoints and end-to-end type safety between your server-side model calls and client-side UI.
Define the Server Action
// app/actions/chat.ts
"use server";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
import { createStreamableValue } from "ai/rsc";
import type { UIMessage } from "@ai-sdk/react";
export async function chat(messages: UIMessage[]) {
const stream = createStreamableValue("");
(async () => {
const result = streamText({
model: openai("gpt-4o"),
system: "You are a helpful coding assistant.",
messages,
});
for await (const text of result.textStream) {
stream.update(text);
}
stream.done();
})();
return { output: stream.value };
}
Consume the Server Action on the Client
// app/chat-sa/page.tsx
"use client";
import { useState } from "react";
import { readStreamableValue } from "ai/rsc";
import { chat } from "@/app/actions/chat";
import type { UIMessage } from "@ai-sdk/react";
export default function ChatWithServerAction() {
const [messages, setMessages] = useState<UIMessage[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim()) return;
const userMessage: UIMessage = {
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: input }],
};
const updatedMessages = [...messages, userMessage];
setMessages(updatedMessages);
setInput("");
setIsLoading(true);
const { output } = await chat(updatedMessages);
let assistantText = "";
for await (const chunk of readStreamableValue(output)) {
assistantText += chunk;
setMessages([
...updatedMessages,
{
id: crypto.randomUUID(),
role: "assistant",
parts: [{ type: "text", text: assistantText }],
},
]);
}
setIsLoading(false);
}
return (
<div className="mx-auto max-w-2xl p-4">
<h1 className="mb-6 text-2xl font-bold">Chat (Server Actions)</h1>
{/* Render messages and form similar to previous example */}
</div>
);
}
The Server Actions approach has real advantages over API routes: no separate endpoint files, automatic type inference between server and client, seamless integration with React concurrent features, and simpler deployment. That said, the API route approach still works great — pick whichever feels right for your project.
Structured Output with Zod Schemas
Sometimes you don't want free-form text back from the AI. You want structured data — a JSON object with a specific shape that you can render in your UI or save to a database. The AI SDK provides generateObject and streamObject functions that constrain the model output to a Zod schema, giving you type-safe, validated results every time.
Generate a Structured Recipe
// app/actions/recipe.ts
"use server";
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const RecipeSchema = z.object({
name: z.string().describe("The name of the recipe"),
description: z.string().describe("A short description"),
servings: z.number().describe("Number of servings"),
prepTimeMinutes: z.number(),
cookTimeMinutes: z.number(),
ingredients: z.array(
z.object({
item: z.string(),
amount: z.string(),
unit: z.string(),
})
),
steps: z.array(z.string()).describe("Ordered cooking steps"),
difficulty: z.enum(["easy", "medium", "hard"]),
});
export type Recipe = z.infer<typeof RecipeSchema>;
export async function generateRecipe(prompt: string): Promise<Recipe> {
const { object } = await generateObject({
model: openai("gpt-4o"),
schema: RecipeSchema,
prompt: `Generate a detailed recipe for: ${prompt}`,
});
return object;
}
Stream Structured Data Progressively
For larger structured outputs, use streamObject to show partial results as they generate. This is great for dashboards, reports, or any UI where you want to render data as it comes in rather than waiting for the whole thing:
// app/actions/analysis.ts
"use server";
import { streamObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { createStreamableValue } from "ai/rsc";
const AnalysisSchema = z.object({
summary: z.string(),
sentiment: z.enum(["positive", "negative", "neutral"]),
keyPoints: z.array(z.string()),
suggestedActions: z.array(
z.object({
action: z.string(),
priority: z.enum(["high", "medium", "low"]),
})
),
});
export async function analyzeText(text: string) {
const stream = createStreamableValue();
(async () => {
const result = streamObject({
model: openai("gpt-4o"),
schema: AnalysisSchema,
prompt: `Analyze the following text:\n\n${text}`,
});
for await (const partialObject of result.partialObjectStream) {
stream.update(partialObject);
}
stream.done();
})();
return { object: stream.value };
}
The Zod schema does double duty here: it tells the model what structure to generate, and it validates the output at runtime. If the model produces something invalid, the SDK throws a typed error you can handle gracefully. It's one of those things that just works really well once you set it up.
Tool Calling: Let the AI Take Actions
This is where things get really interesting. Tool calling is what turns a text generator into a genuinely useful assistant. You define functions the AI can invoke — fetching weather data, querying a database, calling an external API — and the model decides when and how to use them based on the conversation.
Define and Use Tools in a Route Handler
// app/api/chat-with-tools/route.ts
import { streamText, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
system: `You are a helpful assistant with access to real-time tools.
Use them when the user asks for current information.`,
messages,
tools: {
getWeather: tool({
description: "Get the current weather for a specific city",
parameters: z.object({
city: z.string().describe("The city name"),
unit: z
.enum(["celsius", "fahrenheit"])
.optional()
.default("celsius"),
}),
execute: async ({ city, unit }) => {
// Replace with a real weather API call
const response = await fetch(
`https://api.weatherapi.com/v1/current.json?key=${process.env.WEATHER_API_KEY}&q=${city}`
);
const data = await response.json();
return {
city,
temperature:
unit === "fahrenheit"
? data.current.temp_f
: data.current.temp_c,
unit,
condition: data.current.condition.text,
};
},
}),
searchProducts: tool({
description: "Search the product catalog by name or category",
parameters: z.object({
query: z.string(),
maxResults: z.number().optional().default(5),
}),
execute: async ({ query, maxResults }) => {
// Replace with your actual database query
const products = await db.product.findMany({
where: { name: { contains: query, mode: "insensitive" } },
take: maxResults,
});
return products;
},
}),
},
});
return result.toDataStreamResponse();
}
Render Tool Results in the Chat UI
The useChat hook handles tool call messages automatically. You just need to check for tool invocation parts and render them however you want:
// app/chat-tools/page.tsx
"use client";
import { useChat } from "@ai-sdk/react";
function WeatherCard({ data }: { data: any }) {
return (
<div className="rounded-lg border bg-gradient-to-br from-blue-50 to-blue-100 p-4">
<h3 className="font-semibold">{data.city}</h3>
<p className="text-3xl font-bold">
{data.temperature}°{data.unit === "fahrenheit" ? "F" : "C"}
</p>
<p className="text-gray-600">{data.condition}</p>
</div>
);
}
export default function ChatWithTools() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: "/api/chat-with-tools",
});
return (
<div className="mx-auto max-w-2xl p-4">
{messages.map((message) => (
<div key={message.id} className="mb-4">
<strong>{message.role}:</strong>
{message.parts.map((part, i) => {
if (part.type === "text") {
return <p key={i}>{part.text}</p>;
}
if (part.type === "tool-invocation") {
const { toolName, state, result } = part.toolInvocation;
if (state === "result" && toolName === "getWeather") {
return <WeatherCard key={i} data={result} />;
}
return <p key={i} className="text-gray-400">Loading {toolName}...</p>;
}
return null;
})}
</div>
))}
<form onSubmit={handleSubmit} className="flex gap-2">
<input value={input} onChange={handleInputChange} className="flex-1 rounded border p-2" />
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">Send</button>
</form>
</div>
);
}
So when the user asks "What's the weather in Tokyo?", here's what happens: the model recognizes it should use the getWeather tool, generates a tool call with the city parameter, the SDK executes your function, and the result streams back to the client. The UI then renders a nice WeatherCard instead of plain text. This is the foundation of what people call generative UI — and it's surprisingly easy to build.
Build a Reusable AI Agent
AI SDK v6 introduced the Agent abstraction, which is (in my opinion) one of the most exciting additions. It lets you define a reusable AI entity with its own model, instructions, and tools. Agents can perform multi-step reasoning, calling tools repeatedly until they reach a final answer.
// lib/agents/research-agent.ts
import { openai } from "@ai-sdk/openai";
import { Agent, tool } from "ai";
import { z } from "zod";
export const researchAgent = new Agent({
model: openai("gpt-4o"),
instructions: `You are a research assistant. Use the available tools
to find accurate, up-to-date information. Always cite
your sources and synthesize multiple data points into
clear, actionable summaries.`,
tools: {
webSearch: tool({
description: "Search the web for current information",
parameters: z.object({
query: z.string().describe("The search query"),
}),
execute: async ({ query }) => {
// Integrate with your preferred search API
const results = await searchWeb(query);
return results;
},
}),
readUrl: tool({
description: "Read and extract content from a URL",
parameters: z.object({
url: z.string().url(),
}),
execute: async ({ url }) => {
const response = await fetch(url);
const html = await response.text();
// Parse and return relevant content
return extractContent(html);
},
}),
},
});
Use the Agent in a Server Action
// app/actions/research.ts
"use server";
import { researchAgent } from "@/lib/agents/research-agent";
import { streamText } from "ai";
import { createStreamableValue } from "ai/rsc";
export async function research(query: string) {
const stream = createStreamableValue("");
(async () => {
const result = streamText({
...researchAgent,
prompt: query,
});
for await (const text of result.textStream) {
stream.update(text);
}
stream.done();
})();
return { output: stream.value };
}
The key difference between an agent and a simple streamText call? The agent can make multiple tool calls in sequence, reasoning about results before deciding what to do next. By default, AI SDK v6 allows up to 20 steps before stopping, though you can customize this with the stopWhen parameter.
Swap AI Providers with One Line
One of the SDK's most powerful features — and honestly, the reason I recommend it over rolling your own API calls — is provider agnosticism. Every example above uses OpenAI, but switching to another provider is a one-line change:
// Switch from OpenAI to Anthropic
import { anthropic } from "@ai-sdk/anthropic";
const result = streamText({
model: anthropic("claude-sonnet-4-20250514"),
messages,
});
// Switch to Google Gemini
import { google } from "@ai-sdk/google";
const result = streamText({
model: google("gemini-2.0-flash"),
messages,
});
// Switch to a local model via Ollama
import { ollama } from "ollama-ai-provider";
const result = streamText({
model: ollama("llama3"),
messages,
});
This means you can prototype with a cheaper model, test across multiple providers, and deploy with the best-performing one — all without touching your application logic, tools, or UI code. That kind of flexibility is hard to overstate.
Production Deployment Checklist
Before shipping your AI-powered Next.js app, there are a few things you really don't want to skip. I've learned some of these the hard way.
Environment and Runtime
- Set
maxDurationon your AI route handlers or Server Actions. The default 10-second timeout is way too short for most LLM responses — bump it to 30-60 seconds. - Deploy serverless functions in the same region as your primary users to minimize latency.
- Consider Edge Runtime for latency-sensitive chat routes. It reduces cold start time significantly.
Rate Limiting and Cost Control
- Implement rate limiting on your AI endpoints. Every API call costs money, and a runaway loop or abuse scenario can get expensive fast.
- Set token limits using
maxTokenson every model call. Seriously, every one. - Log token usage from model responses so you can actually monitor your spending.
Error Handling
- Always handle the
errorstate fromuseChat— show meaningful messages when the AI provider is down or rate-limited. - Implement retry logic with exponential backoff for transient failures.
- Use the
onErrorcallback inuseChatfor centralized error tracking.
Security
- Never expose API keys to the client. All model calls must happen on the server (route handlers or Server Actions). This sounds obvious, but it's easy to accidentally import a server module in a client component.
- Validate and sanitize user input before passing it to the model. Prompt injection is a real and growing threat.
- If your tools access databases or external APIs, apply the same authorization checks you would for any other server endpoint.
// next.config.ts — example production configuration
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "2mb", // Limit payload size for AI requests
},
},
};
export default nextConfig;
Frequently Asked Questions
What is the Vercel AI SDK and do I need it for AI in Next.js?
The Vercel AI SDK is an open-source TypeScript library that provides a unified interface for working with AI models from OpenAI, Anthropic, Google, and others. You don't strictly need it — you could call AI APIs directly with fetch — but the SDK handles streaming, tool calling, structured output, and client-side state management so you don't have to build all that plumbing yourself. It's free and open source.
Should I use API routes or Server Actions for AI chat?
Both work well, and AI SDK v6 supports either approach. API routes (app/api/chat/route.ts) are the default path for the useChat hook and require less setup. Server Actions eliminate separate endpoint files and give you end-to-end type safety, but you'll need to manage more state on the client. For a quick chat UI, go with API routes. For complex apps where type safety and co-location matter, Server Actions are worth it.
How do I switch between AI providers without rewriting my app?
Just change the import and model initialization. Swap openai("gpt-4o") with anthropic("claude-sonnet-4-20250514") and everything else — tools, prompts, streaming logic, UI code — stays exactly the same.
Is streaming necessary for AI responses in Next.js?
Not strictly, but it makes a huge difference for user experience. Without streaming, users stare at a loading spinner for 5-30 seconds. With streaming, the first tokens appear in under a second. Use streamText for anything user-facing and generateText for background tasks where latency to first token doesn't matter.
How much does it cost to run AI features in a Next.js app?
It depends on the model and usage volume. GPT-4o costs roughly $2.50 per million input tokens and $10 per million output tokens as of early 2026. A typical chat exchange uses about 500-2,000 tokens total. For a small app with a few hundred daily users, expect $10-50 per month. Always set maxTokens limits and implement rate limiting to avoid surprise bills.
Article changelog (1)
- — Expanded with TL;DR, table of contents, or additional sections


