Skip to content
FLAVIO COPES
flaviocopes.com
2026

Vercel AI SDK tutorial: build a streaming chatbot with tools

By

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:

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:

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.

Tagged: AI · All topics
~~~

Related posts about ai: