Skip to content

Vercel AI SDK v5: What I Actually Use in Real Apps

Vercel AI SDK v5: What I Actually Use in Real Apps

Confession: I shipped my first chatbot in 2023 by hand-rolling a fetch streaming parser with a useEffect that I’d be embarrassed to show you now. I knew Vercel had an AI SDK. I also knew that “AI SDK” in 2023 mostly meant “a wrapper that costs you a week of debugging the moment you do anything weird.” So I skipped it.

I gave the SDK another try this spring when v5 dropped, partly because a client asked me to ship a support chatbot with tool calling and streaming UI in a week, partly because I was tired of maintaining my own SSE parser. Three projects later I’m still using it, and I’m using it differently than I expected. Some of the v5 abstractions are exactly what I needed. A few I’ve quietly worked around.

Here’s what I actually reach for, what I skip, and what I’d warn you about before you pnpm add ai in a real codebase.

Why I bothered with v5 at all

The honest answer is tool calling without writing a route handler for each tool. In v4 I had to wire up experimental_StreamData, a custom server endpoint, and a typed dispatcher on the client. It worked. It also meant that every time I added a new tool I touched three files and forgot at least one.

The v5 release moved tool definitions and dispatch into a tools object you pass to streamText, and the matching tool calls land in your message stream automatically. The other change I care about is the typed useChat hook. It actually knows the shape of your tool results now, which means TypeScript catches the case where I rename a tool argument and forget about the React component reading it.

A few things didn’t change. The SDK still leans on Next.js conventions hard. If you’re on a Hono or Fastify backend you can use the core functions, but you’ll write more glue than the docs suggest. And the SDK still doesn’t have an opinion about cost tracking, so you’ll bolt on your own usage logger.

Here’s the rough shape of the difference, simplified:

// v4 - manual stream plus custom dispatch
import { OpenAIStream, StreamingTextResponse } from 'ai'
const stream = OpenAIStream(completion, { experimental_onToolCall: dispatch })
return new StreamingTextResponse(stream)
// v5 - tools live in the call
import { streamText, tool } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'

const result = await streamText({
  model: openai('gpt-4.1'),
  messages,
  tools: {
    searchDocs: tool({
      description: 'Search the internal docs',
      inputSchema: z.object({ query: z.string() }),
      execute: async ({ query }) => searchIndex(query),
    }),
  },
})
return result.toUIMessageStreamResponse()

useChat with typed message parts

useChat got real types for message.parts in v5, which sounds boring until you’ve spent an evening guessing whether a part was a text chunk or a tool result. Each part is discriminated by type, and the SDK gives you helpers for the common shapes. The other useful change is that useChat exposes a sendMessage function instead of overloading the form-submit handler, so I can call it from a button, a voice input, or a queued reconnect without faking a synthetic event.

'use client'
import { useChat } from '@ai-sdk/react'

export function Chat() {
  const { messages, sendMessage } = useChat()
  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.parts.map((p, i) => {
            if (p.type === 'text') return <p key={i}>{p.text}</p>
            if (p.type === 'tool-searchDocs')
              return <SearchHits key={i} results={p.output} />
            return null
          })}
        </div>
      ))}
      <button onClick={() => sendMessage({ text: 'find the refund policy' })}>
        Ask
      </button>
    </div>
  )
}

One catch I hit early: the tool-<name> part type is generated from your server tool name, so if you rename searchDocs to findDocs you have to update both the server and the rendering. TypeScript catches it if you set up the types right. It stays silent if you don’t.

Tool calling that didn’t make me write a router

The thing I actually like in v5 is that tool calls show up as message parts in the same stream as the assistant text. In v4 I had a separate data channel for tool output that I’d merge in render. In v5 the part sits right next to the text, so my UI just maps over m.parts and renders whatever it sees.

Real example from my last project, a support bot that can pull an order status and a ticket history:

import { streamText, tool, stepCountIs } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'

const result = await streamText({
  model: openai('gpt-4.1'),
  system: ORDER_SUPPORT_PROMPT,
  messages,
  tools: {
    getOrder: tool({
      description: 'Look up an order by ID',
      inputSchema: z.object({ orderId: z.string() }),
      execute: async ({ orderId }) => {
        const res = await orders.find(orderId)
        if (!res) throw new Error('Order not found')
        return res
      },
    }),
    getTickets: tool({
      description: 'List support tickets for a customer',
      inputSchema: z.object({ customerId: z.string() }),
      execute: async ({ customerId }) => tickets.listFor(customerId),
    }),
  },
  stopWhen: stepCountIs(4),
})

stopWhen: stepCountIs(4) saved me from a runaway loop on the first day. If your tools call other tools and you don’t cap the steps, you’ll get a $7 invoice for one chat session. Ask me how I know. I cover the wider problem of agents wandering off in my post on context engineering versus prompt engineering, and the short version is “always give agents a budget and a stop condition.”

You can read the full tool-calling reference in the SDK docs if you want the longer list of options. The two flags I touch most often are stopWhen and toolChoice.

Streaming UI: when generative UI is worth it

Generative UI, where the model returns a structured object and you render a React component for it, is the headline feature in v5 marketing. I use it for two things: tabular results like order details and search hits, and explicit confirmation cards. For free-form prose I just stream text.

The decision rule I use is simple. If the data is structured and the user is going to act on it, stream an object. If the user might want to copy-paste the answer, stream text.

import { streamObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'

const result = await streamObject({
  model: openai('gpt-4.1'),
  schema: z.object({
    summary: z.string(),
    actions: z.array(z.object({
      label: z.string(),
      kind: z.enum(['link', 'button']),
    })),
  }),
  prompt: assistantPrompt,
})

Where I don’t use it: anything the user might want to copy-paste. A user expects to highlight text from a chat. A React card breaks that flow. So I default to text and only reach for structured generation when the data really is structured. The same caution shows up in my notes on agentic coding: pick the cheapest representation that gets the job done.

Where the v5 abstraction got in my way

A few things I had to work around.

The SDK assumes your model provider supports streaming. If you’re on a self-hosted Llama setup behind an OpenAI-compat shim that doesn’t stream, you can still use generateText, but you lose the part-streaming UX. I worked around it by chunking the final string client-side, which feels like a workaround because it is one.

Observability is the other gap I felt. I want token counts and per-tool latency without rolling my own logger from scratch. The SDK gives you onFinish and usage on the result, which is enough to roll your own, and I wrote one in an afternoon. Still, it’s the kind of thing I wish came in the box. If you need real tracing, plug in Langfuse or Helicone before you ship.

The other thing worth knowing: the abstractions are sticky. Once your UI maps over message.parts, swapping to a different SDK like LangChain.js or raw OpenAI streaming means rewriting the render layer. The v5 part shape is fine. It’s just yours forever once you adopt it. I’d build the same way if I started over, but go in with eyes open.

What I’d build with it this week

If you’ve been holding off on the AI SDK because v4 burned you, it’s worth a fresh look. Pick one project: a support chatbot, or a small agent that does two tool calls and stops. Start with streamText, a single tool, and useChat on the client. Get the round-trip working in an afternoon.

Two things I’d add on day two:

  1. A stopWhen budget on every streamText call. The default has no ceiling, and that’s the most expensive default I know in JS land right now.
  2. A logger that captures usage.totalTokens and tool-call latencies. Even a console.log keeps you honest until you wire up real tracing.

I do this kind of work day to day, and I write up more of it on my site. The SDK isn’t magic. It’s the first AI library I’ve used where the boring parts feel boring, which is the highest compliment I can give a library.