{"id":360,"date":"2026-06-23T13:05:01","date_gmt":"2026-06-23T13:05:01","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/vercel-ai-sdk-v5-what-i-actually-use-in-real-apps\/"},"modified":"2026-06-23T13:05:01","modified_gmt":"2026-06-23T13:05:01","slug":"vercel-ai-sdk-v5-what-i-actually-use-in-real-apps","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/vercel-ai-sdk-v5-what-i-actually-use-in-real-apps\/","title":{"rendered":"Vercel AI SDK v5: What I Actually Use in Real Apps"},"content":{"rendered":"<p>Confession: I shipped my first chatbot in 2023 by hand-rolling a fetch streaming parser with a <code>useEffect<\/code> that I&rsquo;d be embarrassed to show you now. I knew Vercel had an AI SDK. I also knew that &ldquo;AI SDK&rdquo; in 2023 mostly meant &ldquo;a wrapper that costs you a week of debugging the moment you do anything weird.&rdquo; So I skipped it.<\/p>\n<p>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&rsquo;m still using it, and I&rsquo;m using it differently than I expected. Some of the v5 abstractions are exactly what I needed. A few I&rsquo;ve quietly worked around.<\/p>\n<p>Here&rsquo;s what I actually reach for, what I skip, and what I&rsquo;d warn you about before you <code>pnpm add ai<\/code> in a real codebase.<\/p>\n<h2 id=\"why-i-bothered-with-v5-at-all\">Why I bothered with v5 at all<\/h2>\n<p>The honest answer is tool calling without writing a route handler for each tool. In v4 I had to wire up <code>experimental_StreamData<\/code>, 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.<\/p>\n<p>The <a href=\"https:\/\/vercel.com\/blog\/ai-sdk-5\" rel=\"nofollow noopener\" target=\"_blank\">v5 release<\/a> moved tool definitions and dispatch into a <code>tools<\/code> object you pass to <code>streamText<\/code>, and the matching tool calls land in your message stream automatically. The other change I care about is the typed <code>useChat<\/code> 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.<\/p>\n<p>A few things didn&rsquo;t change. The SDK still leans on Next.js conventions hard. If you&rsquo;re on a Hono or Fastify backend you can use the core functions, but you&rsquo;ll write more glue than the docs suggest. And the SDK still doesn&rsquo;t have an opinion about cost tracking, so you&rsquo;ll bolt on your own usage logger.<\/p>\n<p>Here&rsquo;s the rough shape of the difference, simplified:<\/p>\n<pre><code class=\"language-ts\">\/\/ v4 - manual stream plus custom dispatch\nimport { OpenAIStream, StreamingTextResponse } from 'ai'\nconst stream = OpenAIStream(completion, { experimental_onToolCall: dispatch })\nreturn new StreamingTextResponse(stream)\n<\/code><\/pre>\n<pre><code class=\"language-ts\">\/\/ v5 - tools live in the call\nimport { streamText, tool } from 'ai'\nimport { openai } from '@ai-sdk\/openai'\nimport { z } from 'zod'\n\nconst result = await streamText({\n  model: openai('gpt-4.1'),\n  messages,\n  tools: {\n    searchDocs: tool({\n      description: 'Search the internal docs',\n      inputSchema: z.object({ query: z.string() }),\n      execute: async ({ query }) =&gt; searchIndex(query),\n    }),\n  },\n})\nreturn result.toUIMessageStreamResponse()\n<\/code><\/pre>\n<h2 id=\"usechat-with-typed-message-parts\">useChat with typed message parts<\/h2>\n<p><code>useChat<\/code> got real types for <code>message.parts<\/code> in v5, which sounds boring until you&rsquo;ve spent an evening guessing whether a part was a text chunk or a tool result. Each part is discriminated by <code>type<\/code>, and the SDK gives you helpers for the common shapes. The other useful change is that <code>useChat<\/code> exposes a <code>sendMessage<\/code> 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.<\/p>\n<pre><code class=\"language-tsx\">'use client'\nimport { useChat } from '@ai-sdk\/react'\n\nexport function Chat() {\n  const { messages, sendMessage } = useChat()\n  return (\n    &lt;div&gt;\n      {messages.map(m =&gt; (\n        &lt;div key={m.id}&gt;\n          {m.parts.map((p, i) =&gt; {\n            if (p.type === 'text') return &lt;p key={i}&gt;{p.text}&lt;\/p&gt;\n            if (p.type === 'tool-searchDocs')\n              return &lt;SearchHits key={i} results={p.output} \/&gt;\n            return null\n          })}\n        &lt;\/div&gt;\n      ))}\n      &lt;button onClick={() =&gt; sendMessage({ text: 'find the refund policy' })}&gt;\n        Ask\n      &lt;\/button&gt;\n    &lt;\/div&gt;\n  )\n}\n<\/code><\/pre>\n<p>One catch I hit early: the <code>tool-&lt;name&gt;<\/code> part type is generated from your server tool name, so if you rename <code>searchDocs<\/code> to <code>findDocs<\/code> 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&rsquo;t.<\/p>\n<h2 id=\"tool-calling-that-didnt-make-me-write-a-router\">Tool calling that didn&rsquo;t make me write a router<\/h2>\n<p>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 <code>data<\/code> channel for tool output that I&rsquo;d merge in render. In v5 the part sits right next to the text, so my UI just maps over <code>m.parts<\/code> and renders whatever it sees.<\/p>\n<p>Real example from my last project, a support bot that can pull an order status and a ticket history:<\/p>\n<pre><code class=\"language-ts\">import { streamText, tool, stepCountIs } from 'ai'\nimport { openai } from '@ai-sdk\/openai'\nimport { z } from 'zod'\n\nconst result = await streamText({\n  model: openai('gpt-4.1'),\n  system: ORDER_SUPPORT_PROMPT,\n  messages,\n  tools: {\n    getOrder: tool({\n      description: 'Look up an order by ID',\n      inputSchema: z.object({ orderId: z.string() }),\n      execute: async ({ orderId }) =&gt; {\n        const res = await orders.find(orderId)\n        if (!res) throw new Error('Order not found')\n        return res\n      },\n    }),\n    getTickets: tool({\n      description: 'List support tickets for a customer',\n      inputSchema: z.object({ customerId: z.string() }),\n      execute: async ({ customerId }) =&gt; tickets.listFor(customerId),\n    }),\n  },\n  stopWhen: stepCountIs(4),\n})\n<\/code><\/pre>\n<p><code>stopWhen: stepCountIs(4)<\/code> saved me from a runaway loop on the first day. If your tools call other tools and you don&rsquo;t cap the steps, you&rsquo;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 <a href=\"https:\/\/abrarqasim.com\/blog\/context-engineering-vs-prompt-engineering-why-my-agents-failed\/\" rel=\"noopener\">post on context engineering versus prompt engineering<\/a>, and the short version is &ldquo;always give agents a budget and a stop condition.&rdquo;<\/p>\n<p>You can read the full <a href=\"https:\/\/sdk.vercel.ai\/docs\/foundations\/tools\" rel=\"nofollow noopener\" target=\"_blank\">tool-calling reference in the SDK docs<\/a> if you want the longer list of options. The two flags I touch most often are <code>stopWhen<\/code> and <code>toolChoice<\/code>.<\/p>\n<h2 id=\"streaming-ui-when-generative-ui-is-worth-it\">Streaming UI: when generative UI is worth it<\/h2>\n<p>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.<\/p>\n<p>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.<\/p>\n<pre><code class=\"language-ts\">import { streamObject } from 'ai'\nimport { openai } from '@ai-sdk\/openai'\nimport { z } from 'zod'\n\nconst result = await streamObject({\n  model: openai('gpt-4.1'),\n  schema: z.object({\n    summary: z.string(),\n    actions: z.array(z.object({\n      label: z.string(),\n      kind: z.enum(['link', 'button']),\n    })),\n  }),\n  prompt: assistantPrompt,\n})\n<\/code><\/pre>\n<p>Where I don&rsquo;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 <a href=\"https:\/\/abrarqasim.com\/blog\/agentic-coding-why-the-feedback-loop-beats-a-smarter-model\/\" rel=\"noopener\">agentic coding<\/a>: pick the cheapest representation that gets the job done.<\/p>\n<h2 id=\"where-the-v5-abstraction-got-in-my-way\">Where the v5 abstraction got in my way<\/h2>\n<p>A few things I had to work around.<\/p>\n<p>The SDK assumes your model provider supports streaming. If you&rsquo;re on a self-hosted Llama setup behind an OpenAI-compat shim that doesn&rsquo;t stream, you can still use <code>generateText<\/code>, 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.<\/p>\n<p>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 <code>onFinish<\/code> and <code>usage<\/code> on the result, which is enough to roll your own, and I wrote one in an afternoon. Still, it&rsquo;s the kind of thing I wish came in the box. If you need real tracing, plug in Langfuse or Helicone before you ship.<\/p>\n<p>The other thing worth knowing: the abstractions are sticky. Once your UI maps over <code>message.parts<\/code>, swapping to a different SDK like LangChain.js or raw OpenAI streaming means rewriting the render layer. The v5 part shape is fine. It&rsquo;s just yours forever once you adopt it. I&rsquo;d build the same way if I started over, but go in with eyes open.<\/p>\n<h2 id=\"what-id-build-with-it-this-week\">What I&rsquo;d build with it this week<\/h2>\n<p>If you&rsquo;ve been holding off on the AI SDK because v4 burned you, it&rsquo;s worth a fresh look. Pick one project: a support chatbot, or a small agent that does two tool calls and stops. Start with <code>streamText<\/code>, a single tool, and <code>useChat<\/code> on the client. Get the round-trip working in an afternoon.<\/p>\n<p>Two things I&rsquo;d add on day two:<\/p>\n<ol>\n<li>A <code>stopWhen<\/code> budget on every <code>streamText<\/code> call. The default has no ceiling, and that&rsquo;s the most expensive default I know in JS land right now.<\/li>\n<li>A logger that captures <code>usage.totalTokens<\/code> and tool-call latencies. Even a <code>console.log<\/code> keeps you honest until you wire up real tracing.<\/li>\n<\/ol>\n<p>I do this kind of work day to day, and I write up more of it <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">on my site<\/a>. The SDK isn&rsquo;t magic. It&rsquo;s the first AI library I&rsquo;ve used where the boring parts feel boring, which is the highest compliment I can give a library.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>My honest take on Vercel AI SDK v5 after three production projects, plus the useChat and tool-calling gotchas I wish someone had warned me about first.<\/p>\n","protected":false},"author":2,"featured_media":359,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"My honest take on Vercel AI SDK v5 after three production projects, plus the useChat and tool-calling gotchas I wish someone had warned me about first.","rank_math_focus_keyword":"vercel ai sdk v5","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[4,388],"tags":[28,404,61,41,405,403],"class_list":["post-360","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai","category-next-js","tag-ai","tag-ai-sdk","tag-nextjs","tag-react","tag-tool-calling-2","tag-vercel-ai-sdk-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/360","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=360"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/360\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/359"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=360"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=360"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=360"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}