Migrating the Vercel AI SDK From v2 to v3
10 min read · updated August 11, 2026
In v2 the AI SDK was glue: you called a provider’s own SDK, wrapped its stream in an adapter, and returned it in a special Response subclass. In v3 the SDK became the interface you call, with the provider behind it. That is a bigger change than a major version usually implies, and it is almost entirely in the route handler.
What v3 actually introduced
Two separate things arrived under the v3 heading, and knowing which one you are adopting saves confusion. The 3.0 release was about generative UI on the server — streaming React components rather than only text. The 3.1 release introduced AI SDK Core, which is the part most upgrades care about: four functions, generateText, streamText, generateObject and streamObject, that take a model object rather than a provider client, per Vercel’s 3.0 to 3.1 guide.
The model object comes from a provider package — @ai-sdk/openai, @ai-sdk/anthropic and so on — installed separately from ai itself. This is the same package-split pattern LangChain and LlamaIndex both landed on in the same period, for the same reason: one package cannot carry every vendor’s dependencies and release cadence.
The route handler, before and after
The v2 shape, which you will recognise:
import OpenAI from "openai";
import { OpenAIStream, StreamingTextResponse } from "ai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(req: Request) {
const { messages } = await req.json();
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
stream: true,
messages,
});
const stream = OpenAIStream(response);
return new StreamingTextResponse(stream);
}The v3 Core shape:
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: openai("gpt-4o-mini"),
messages,
temperature: 0.7,
maxTokens: 500,
});
return result.toAIStreamResponse();
}Three structural changes. The provider SDK is gone from your file entirely; openai("gpt-4o-mini") returns a model object that the SDK knows how to drive. The adapter function is gone, because the provider package is the adapter. And the request parameters are now the SDK’s names rather than the vendor’s — maxTokens in camelCase rather than max_tokens, and the same for every other setting, which is a rename you will make in a lot of places if you were building request bodies dynamically.
The migration path that keeps a large app runnable is one route at a time. The two styles coexist in a v3 install — v2’s adapter exports were not removed until 4.0 — so you can convert the chat route on Monday and the completion route on Tuesday.
The other two Core functions are the reason a lot of teams did this upgrade at all. generateObject and streamObject take a schema and return typed data rather than a string you parse, which replaces a hand-written layer of “ask for JSON, hope, repair, validate” on the other side of the call:
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: z.object({
title: z.string(),
tags: z.array(z.string()),
}),
prompt: "Extract a title and tags from: " + article,
});Worth knowing that this is a convenience over a capability the providers expose differently, and the SDK picks a strategy per model. Where a model supports constrained decoding the schema is enforced by the provider; where it does not, the schema becomes instructions plus validation, and a failure is a thrown error rather than a malformed string. The distinction matters for what you do on failure, and it is the same distinction as elsewhere in the ecosystem.
The client half
Much less changes here, which is the good news. useChat and useCompletion keep their shape: you get messages, input, handleInputChange, handleSubmit and isLoading, and a component written against v2 mostly keeps working against a converted route because the wire protocol between them is the thing both sides agree on.
The one thing to watch is that the protocol is not one protocol. A plain text stream and the SDK’s richer data-stream format are different wire formats, and a hook expecting one while a route sends the other produces the failure where tokens arrive but nothing renders, or where the message content contains visible protocol prefixes. If you convert a route to streamText and the UI goes strange rather than broken, this is the mismatch to check first — the general shape of the problem is covered under streaming transports.
What does not survive the abstraction
A common interface across providers is worth having and it is not free. The v2 code passed a request body straight to one vendor, so anything that vendor accepted, you could send. The v3 call goes through a normalised parameter set, and what falls outside it needs a different route.
- Vendor-only request fields. Parameters that exist for one provider and have no counterpart elsewhere are not part of the common settings. They are reachable through the provider-specific escape hatch in the SDK rather than as top-level options, and the name of that escape hatch has changed between majors — check the current provider documentation rather than copying a snippet.
- Response metadata you were reading off the raw object. Code that read a vendor-specific field from the provider SDK’s response now has to find it in the normalised result or in the provider metadata. Fields with no equivalent on other providers are precisely the ones a common interface has nowhere to put.
- Finish reasons and usage. Both are normalised, which is usually what you want, and it does mean the exact string you were switching on may have become a different vocabulary. Anything doing
if (choice.finish_reason === "length")needs re-reading against the SDK’s own finish-reason values rather than the provider’s. - Error types. Errors arrive as the SDK’s error classes wrapping the provider’s response rather than as the provider SDK’s own. A
catchblock matching on the old classes stops matching, silently, in the same way the openai-python rename does.
The response helper keeps being renamed
One honest caveat about the snippet above. The method that turns a streamText result into an HTTP response has been renamed more than once across majors: the 3.x-era name toAIStreamResponse is attested by the 4.0 migration guide’s rename list, which replaces the AI-stream methods with data-stream ones, and the name has moved again since. The live 3.1 guide now shows the current name rather than the one that shipped with 3.1.
So: take the shape from this page and the exact method name from your installed version. Object.keys on the result object, or your editor’s autocomplete against the installed types, is more reliable than any documentation page for this one field.
Doing the upgrade
- Upgrade
aito the 3.x line and install the provider package for each provider you use. Do not remove the vendor SDK yet; the unconverted routes still need it. - Convert one route. Replace the vendor client with the provider model, the vendor call with
streamText, and the adapter with the result’s response helper. - Rename the request parameters to the SDK’s camelCase names and check each one exists — a setting the SDK does not recognise is not an error, it is an ignored option.
- Load the page and confirm tokens still stream. A response that arrives all at once at the end means the handler is buffering, not that streaming is broken.
- Repeat per route, then delete the vendor SDK from
package.jsononce no import remains. Leaving it installed is how a later duplicate-version problem gets its second copy.