{"id":130,"date":"2026-04-21T05:02:17","date_gmt":"2026-04-21T05:02:17","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/vercel-ai-sdk-v5-moving-off-raw-openai\/"},"modified":"2026-04-21T05:02:17","modified_gmt":"2026-04-21T05:02:17","slug":"vercel-ai-sdk-v5-moving-off-raw-openai","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/vercel-ai-sdk-v5-moving-off-raw-openai\/","title":{"rendered":"Vercel AI SDK v5: what moving off raw OpenAI actually bought me"},"content":{"rendered":"<p>I spent two hours last Thursday rewriting the chat endpoint of a side project because my own code was driving me up a wall. I&rsquo;d built it six months ago with the raw OpenAI Node SDK, a custom SSE stream, a home-rolled tool-call parser, and a state machine that felt clever at the time and reads like a ransom note now.<\/p>\n<p>Short version: I moved it to Vercel AI SDK v5 and about a third of the file got deleted. Not refactored. Deleted. That&rsquo;s usually the sign of a good abstraction.<\/p>\n<p>I&rsquo;m not here to tell you the SDK is magic or that it&rsquo;s the future of AI apps. I&rsquo;ll tell you what the migration felt like, which parts saved me real time, which parts I still work around, and the before\/after code so you can judge for yourself. If you&rsquo;re on the fence about adopting it in a Next.js app, this is the honest accounting I wish I&rsquo;d had two weeks ago.<\/p>\n<h2 id=\"what-actually-changed-in-v5\">What actually changed in v5<\/h2>\n<p>The headline for v5 isn&rsquo;t a new feature. It&rsquo;s a cleaner mental model. Earlier versions had you mix imperative helpers like <code>streamText<\/code>, a separate tool-invocation hook, and a <code>useChat<\/code> that quietly did three different jobs depending on how you called it. Fine in small demos. Annoying once you had tool calls, custom state, and a backend that wasn&rsquo;t a Next.js route.<\/p>\n<p>v5 collapses most of that into two primitives that stay out of your way. On the server, <code>streamText<\/code> returns a standard web <code>ReadableStream<\/code> and treats tool calls as first-class. On the client, <code>useChat<\/code> now treats tool invocations as first-class messages instead of a side channel.<\/p>\n<p>There are other wins. Better typed messages. A cleaner <code>generateObject<\/code> with Zod schemas. Simpler MCP support if you&rsquo;re into that. But the thing that actually made me delete code was the tool-call flow. I&rsquo;ll get to that in a minute. First, the old code.<\/p>\n<h2 id=\"the-raw-openai-client-way\">The raw OpenAI client way<\/h2>\n<p>Here&rsquo;s roughly what my endpoint looked like before. I&rsquo;ve stripped it to the bones, but this is the real shape, not a toy:<\/p>\n<pre><code class=\"language-ts\">\/\/ \/app\/api\/chat\/route.ts (before)\nimport OpenAI from &quot;openai&quot;;\n\nconst client = new OpenAI();\n\nexport async function POST(req: Request) {\n  const { messages } = await req.json();\n\n  const stream = await client.chat.completions.create({\n    model: &quot;gpt-4o-mini&quot;,\n    messages,\n    tools: [searchDocsTool],\n    stream: true,\n  });\n\n  const encoder = new TextEncoder();\n  const body = new ReadableStream({\n    async start(controller) {\n      let toolBuffer = &quot;&quot;;\n      for await (const chunk of stream) {\n        const delta = chunk.choices[0]?.delta;\n        if (delta?.content) {\n          controller.enqueue(encoder.encode(delta.content));\n        }\n        if (delta?.tool_calls) {\n          toolBuffer += delta.tool_calls[0].function?.arguments ?? &quot;&quot;;\n        }\n        if (chunk.choices[0]?.finish_reason === &quot;tool_calls&quot;) {\n          const args = JSON.parse(toolBuffer);\n          const result = await runSearchDocs(args);\n          \/\/ ... re-prompt the model with the result\n          \/\/ ... more streaming\n          \/\/ ... 40 more lines i'm not going to paste\n        }\n      }\n      controller.close();\n    },\n  });\n\n  return new Response(body, { headers: { &quot;Content-Type&quot;: &quot;text\/plain&quot; } });\n}\n<\/code><\/pre>\n<p>That &ldquo;40 more lines I&rsquo;m not going to paste&rdquo; is where all the real pain lived. Partial JSON in the tool arguments. Re-calling the model with the tool result. Mapping OpenAI&rsquo;s delta format into something the client could render. A small ceremony every time I wanted to add a new tool. It worked. I shipped it. I also swore at it about once a week.<\/p>\n<h2 id=\"the-ai-sdk-way-side-by-side\">The AI SDK way, side by side<\/h2>\n<p>Here&rsquo;s the same endpoint with v5:<\/p>\n<pre><code class=\"language-ts\">\/\/ \/app\/api\/chat\/route.ts (after)\nimport { streamText, tool } from &quot;ai&quot;;\nimport { openai } from &quot;@ai-sdk\/openai&quot;;\nimport { z } from &quot;zod&quot;;\n\nexport async function POST(req: Request) {\n  const { messages } = await req.json();\n\n  const result = streamText({\n    model: openai(&quot;gpt-4o-mini&quot;),\n    messages,\n    tools: {\n      searchDocs: tool({\n        description: &quot;Search the internal docs&quot;,\n        parameters: z.object({ query: z.string() }),\n        execute: async ({ query }) =&gt; runSearchDocs({ query }),\n      }),\n    },\n  });\n\n  return result.toDataStreamResponse();\n}\n<\/code><\/pre>\n<p>That&rsquo;s not a selective paste. That&rsquo;s the whole file. The tool parsing, the re-prompt loop, the streaming format, the client\/server message contract. The SDK owns all of it. I describe the tool and its <code>execute<\/code> function, and the framework handles the &ldquo;got tool result, now re-call the model&rdquo; step I used to hand-code.<\/p>\n<p>The reason this is so short isn&rsquo;t that v5 added a ton of features. It picked sensible defaults. Tools are Zod-typed. Messages are typed. The response format is a known standard that plugs into <code>useChat<\/code> on the client. You can still eject if you need to: <code>streamText<\/code> returns a full <code>StreamTextResult<\/code> with <code>textStream<\/code>, <code>toolCalls<\/code>, <code>usage<\/code>, and everything else you might reach for. But you don&rsquo;t have to hand-roll the streaming protocol to get started.<\/p>\n<p>I wrote recently about how <a href=\"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-stopped-writing-api-routes\" rel=\"noopener\">Next.js server actions changed where I put my API boundary<\/a>, and the AI SDK fits neatly alongside that shift. The chat endpoint stays an explicit route the SDK can stream from, and everything else goes in a server action.<\/p>\n<h2 id=\"tool-calling-stops-being-a-parsing-exercise\">Tool calling stops being a parsing exercise<\/h2>\n<p>The part of my old code that I really wanted to delete was the tool-call parser. In the raw OpenAI stream, tool arguments arrive as a sequence of partial JSON fragments across many SSE chunks. You buffer them, wait for <code>finish_reason === \"tool_calls\"<\/code>, JSON.parse the buffer, catch failures, re-prompt the model with the result, and then continue streaming text back to the user.<\/p>\n<p>With v5 you define the tool once and you&rsquo;re done:<\/p>\n<pre><code class=\"language-ts\">import { z } from &quot;zod&quot;;\nimport { tool } from &quot;ai&quot;;\n\nexport const searchDocs = tool({\n  description: &quot;Search internal docs by natural-language query&quot;,\n  parameters: z.object({\n    query: z.string(),\n    limit: z.number().int().min(1).max(20).default(5),\n  }),\n  execute: async ({ query, limit }) =&gt; {\n    const hits = await db.search(query, { limit });\n    return { hits };\n  },\n});\n<\/code><\/pre>\n<p>No parsing. No re-prompt plumbing. The SDK calls <code>execute<\/code>, feeds the return value back to the model, and keeps streaming. If <code>execute<\/code> throws, the error surfaces as a typed tool result instead of a cryptic 500.<\/p>\n<p>One thing I wish the <a href=\"https:\/\/ai-sdk.dev\/docs\" rel=\"nofollow noopener\" target=\"_blank\">AI SDK docs<\/a> said louder: the Zod schema you define is the contract. If you&rsquo;ve been writing vague JSON schemas and hoping the model gets it right, switching to Zod forces you to think about the shape you actually want, and the model responds to tighter schemas much better than to loose ones. That alone was worth the migration.<\/p>\n<h2 id=\"streaming-into-the-ui-with-usechat\">Streaming into the UI with useChat<\/h2>\n<p>The client side is where v5 made my React code smaller too. Here&rsquo;s the chat page, trimmed:<\/p>\n<pre><code class=\"language-tsx\">\/\/ \/app\/chat\/page.tsx\n&quot;use client&quot;;\nimport { useChat } from &quot;ai\/react&quot;;\n\nexport default function ChatPage() {\n  const { messages, input, handleInputChange, handleSubmit, status } = useChat();\n\n  return (\n    &lt;form onSubmit={handleSubmit}&gt;\n      {messages.map((m) =&gt; (\n        &lt;div key={m.id} className={m.role}&gt;\n          {m.parts.map((p, i) =&gt;\n            p.type === &quot;text&quot; ? &lt;span key={i}&gt;{p.text}&lt;\/span&gt;\n            : p.type === &quot;tool-invocation&quot; ? &lt;ToolCard key={i} part={p} \/&gt;\n            : null\n          )}\n        &lt;\/div&gt;\n      ))}\n      &lt;input value={input} onChange={handleInputChange} disabled={status === &quot;streaming&quot;} \/&gt;\n    &lt;\/form&gt;\n  );\n}\n<\/code><\/pre>\n<p>The <code>m.parts<\/code> array is the big shift. A message is now a list of parts, not a single string. Text parts. Tool-invocation parts. Reasoning parts for models that emit them. Step-start and step-end markers. You render what you want and ignore what you don&rsquo;t.<\/p>\n<p>That sounds fussy until you use it. It&rsquo;s what lets you show a &ldquo;Looking up docs\u2026&rdquo; card while a tool is running, swap it for the result when it returns, and continue streaming the model&rsquo;s reply below it. No coordination code on my end. I&rsquo;d been faking that with custom SSE events and ref-based state, and all of that got deleted.<\/p>\n<h2 id=\"where-i-still-reach-for-the-raw-sdk\">Where I still reach for the raw SDK<\/h2>\n<p>I don&rsquo;t use the AI SDK for everything. Two cases where I still open <code>openai<\/code> directly:<\/p>\n<p>Batch jobs that don&rsquo;t need streaming. If I&rsquo;m generating 5,000 product descriptions overnight, the raw SDK with a simple concurrency wrapper is cheaper on my attention. No streaming, no tool calls, no UI. The abstractions don&rsquo;t earn their keep.<\/p>\n<p>Tight token accounting. The SDK gives you <code>usage<\/code> at the end of a stream. If I need per-message billing accounting and I&rsquo;m doing odd prompt-caching tricks on the vendor side, reading raw completion chunks is easier than fighting the wrapper.<\/p>\n<p>The other thing worth knowing: the SDK is provider-agnostic in name but OpenAI-shaped in practice. It works fine with Anthropic, Google, and others via their adapters, but a tool-heavy app assumes the provider supports <a href=\"https:\/\/platform.openai.com\/docs\/guides\/function-calling\" rel=\"nofollow noopener\" target=\"_blank\">OpenAI-style function calling<\/a>. If you&rsquo;re targeting a model that doesn&rsquo;t, read the adapter docs carefully before you bet your roadmap on it.<\/p>\n<p>I build a lot of small internal tools around this stack. You can see a few of them in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my work<\/a>. For anything chat-shaped I default to v5 now. For everything else, I still pick the simplest thing that works.<\/p>\n<h2 id=\"what-id-do-this-weekend-if-i-were-you\">What I&rsquo;d do this weekend if I were you<\/h2>\n<p>If you already have a Next.js chat app on the raw OpenAI SDK, spend Saturday doing this migration on a branch. You don&rsquo;t have to commit to anything. Install <code>ai<\/code> and <code>@ai-sdk\/openai<\/code>, rewrite your route with <code>streamText<\/code>, move your tools into <code>tool({ parameters: z.object(...) })<\/code>, and swap your client for <code>useChat<\/code>. Run it against your existing tests.<\/p>\n<p>If the rewrite feels like it&rsquo;s saving you code, merge it. If it&rsquo;s not, you&rsquo;ve learned something real about where the abstraction earns its keep for your app. I wasn&rsquo;t sure either until I saw my own diff.<\/p>\n<p>One last thing: don&rsquo;t port your prompts verbatim. The SDK sends a slightly different system-message shape, and prompts that leaned on quirks in the raw API often need a light rewrite. Budget an hour for that and you&rsquo;ll save yourself a confused afternoon.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I migrated a Next.js chat endpoint from the raw OpenAI SDK to Vercel AI SDK v5. Here&#8217;s the before\/after code, where it wins, and where I still go raw.<\/p>\n","protected":false},"author":2,"featured_media":129,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I migrated a Next.js chat endpoint from the raw OpenAI SDK to Vercel AI SDK v5. Here's the before\/after code, where it wins, and where I still go raw.","rank_math_focus_keyword":"vercel ai sdk v5","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[4],"tags":[33,61,106,41,107,105],"class_list":["post-130","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai","tag-ai-engineering-2","tag-nextjs","tag-openai","tag-react","tag-tool-calling","tag-vercel-ai-sdk"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/130","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=130"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/130\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/129"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=130"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=130"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=130"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}