Vercel AI SDK tutorial: build a streaming chatbot with tools
By Flavio Copes
Build a streaming Next.js chatbot with Vercel AI SDK v7, then add a typed tool call, structured output with Zod, and deployment safeguards.
The Vercel AI SDK is an open source TypeScript toolkit for building AI features.
It gives you one interface for many model providers. It also handles streaming, tool calls, structured data, and chat state.
In this tutorial we’ll build a small support chatbot. It will stream its response and call a tool when someone asks about an order.
AI SDK and AI Gateway are different
The names can be confusing, so let’s clear this up first.
The AI SDK is the library we use in our code. It provides functions like generateText(), streamText(), and tool().
The Vercel AI Gateway sits between our application and model providers. It handles model access, routing, fallbacks, budgets, and usage logs.
You can use the AI SDK without the Gateway. You can also call the Gateway without the AI SDK.
They work well together, and we’ll use both here.
Create the Next.js app
Create a new Next.js application:
npx create-next-app@latest support-chat
cd support-chat
Choose TypeScript and the App Router when prompted.
Now install the AI SDK, its React package, and Zod:
npm install ai @ai-sdk/react zod
Zod lets us describe the data accepted by our tool.
Connect a model
The shortest setup uses a model through Vercel AI Gateway:
model: 'openai/gpt-5.2'
When the app runs on Vercel, it can authenticate to the Gateway using Vercel OIDC. This avoids storing a long-lived Gateway key in production.
Link the local directory to a Vercel project:
npx vercel link
Then pull the development environment:
npx vercel env pull
Alternatively, create an AI Gateway API key and add it to .env.local:
AI_GATEWAY_API_KEY=your_key
Never commit this file.
Create the chat route
The browser will send messages to a Route Handler. The handler passes those messages to the model and streams the response back.
Create app/api/chat/route.ts:
import {
convertToModelMessages,
stepCountIs,
streamText,
tool,
type UIMessage,
} from 'ai'
import { z } from 'zod'
export const maxDuration = 30
export async function POST(request: Request) {
const { messages }: { messages: UIMessage[] } = await request.json()
const result = streamText({
model: 'openai/gpt-5.2',
instructions: 'You help customers track bookstore orders.',
messages: await convertToModelMessages(messages),
stopWhen: stepCountIs(3),
tools: {
getOrder: tool({
description: 'Get the current status of a bookstore order',
inputSchema: z.object({
orderId: z.string().describe('The order ID, such as BOOK-2048'),
}),
execute: async ({ orderId }) => ({
orderId,
status: 'shipped',
expectedDelivery: 'July 23',
}),
}),
},
})
return result.toUIMessageStreamResponse()
}
streamText() starts returning data as the model generates it.
The getOrder tool has three parts:
- a description that tells the model when to use it
- an
inputSchemathat validates its input - an
executefunction that performs the work
Our example returns fixed data. In a real app, execute would query your database or call an order API.
stepCountIs(3) lets the model call the tool, receive its result, and write a final answer. Without another step, you might get the tool result but no natural-language response.
Build the chat interface
Now replace app/page.tsx with this component:
'use client'
import { useState } from 'react'
import { useChat } from '@ai-sdk/react'
export default function Home() {
const [input, setInput] = useState('')
const { messages, sendMessage, status } = useChat()
function submit(event: React.FormEvent) {
event.preventDefault()
const text = input.trim()
if (!text) return
sendMessage({ text })
setInput('')
}
return (
<main>
<h1>Bookstore support</h1>
{messages.map(message => (
<div key={message.id}>
<strong>{message.role === 'user' ? 'You' : 'Support'}</strong>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <p key={index}>{part.text}</p>
}
return null
})}
</div>
))}
<form onSubmit={submit}>
<input
value={input}
onChange={event => setInput(event.target.value)}
placeholder='Where is order BOOK-2048?'
/>
<button disabled={status !== 'ready'}>Send</button>
</form>
</main>
)
}
useChat() sends messages to /api/chat by default.
Notice that we render message.parts, not a content string. A message can contain text, reasoning, tool calls, tool results, and other data. The parts array keeps those values separate.
Start the app:
npm run dev
Open http://localhost:3000 and ask:
Where is order BOOK-2048?
The model should call getOrder and explain that the order shipped.
Render the tool result
Our first interface only displays text. We can also show the tool while it runs.
Add another condition inside message.parts.map():
if (part.type === 'tool-getOrder') {
if (part.state === 'output-available') {
return (
<p key={index}>
Order {part.input.orderId}: {part.output.status}
</p>
)
}
return <p key={index}>Looking up the order...</p>
}
The tool name becomes part of the type: tool-getOrder.
The UI can now distinguish a pending tool call from its result. This is much better than making the model turn every piece of data into prose.
Generate structured data
Chat is only one use for the AI SDK.
Suppose we want to turn a support message into data our application can store. Use Output.object() with a Zod schema:
import { generateText, Output } from 'ai'
import { z } from 'zod'
const { output } = await generateText({
model: 'openai/gpt-5.2',
output: Output.object({
schema: z.object({
category: z.enum(['delivery', 'payment', 'return']),
urgent: z.boolean(),
summary: z.string(),
}),
}),
prompt: 'My package says delivered, but it is not here.',
})
console.log(output)
The result is a typed object:
{
category: 'delivery',
urgent: true,
summary: 'Customer cannot find a package marked as delivered'
}
Use structured output when your code needs data. Use normal text when a person needs an answer.
Add production limits
An AI route costs money every time someone calls it.
Before publishing, add authentication and rate limiting. Also set a budget on the Gateway key or team.
Validate tool inputs even though the SDK already applies the schema. The schema proves the value has the right shape. It does not prove that the current user may access that order.
For example, query by both order ID and signed-in customer ID:
const order = await db.orders.findFirst({
where: {
id: orderId,
customerId: user.id,
},
})
Never let the model decide authorization.
Deploy the chatbot
Deploy the project with:
npx vercel
This creates a preview deployment. Test streaming and the tool call there.
When it works, create the production deployment:
npx vercel --prod
The general Vercel tutorial explains preview environments, domains, logs, and rollbacks in more detail.
We now have the important AI SDK pieces working together:
streamText()streams the answeruseChat()manages the browser chat statetool()connects the model to application code- Zod validates tool inputs
Output.object()returns typed structured data
This same pattern scales from a small support box to a larger agent. The number of tools can grow, but the basic loop stays the same.
Related posts about ai: