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 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.
For a full walkthrough of the Gateway itself, see my Vercel AI Gateway tutorial.
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 show a pending tool call and its result separately, without 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.
That’s a general LLM classifying a message. For typed choices with probabilities, a decision model like Jev (System One) is built for that job.
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 Vercel deployment documentation explains preview environments, domains, logs, and rollbacks in more detail.
This pattern scales from a small support box to a larger agent. You can add more tools without changing the basic loop.
Want me to talk about your product? You can sponsor this site.