Skip to content

Topic · A8

Vercel AI SDK v4 → v5/v6 migration: the codemod-gap ruleset (2026)

The official codemod handles renames. It cannot migrate persisted DB messages, stopWhen logic, or the UIMessage parts array. Teams with persisted message history face 2-6 weeks of engineering work. Here's the gap map.

# Vercel AI SDK v4 → v5/v6 migration: the codemod-gap ruleset The official @vercel/ai-codemod will run cleanly against most v4 codebases and leave you with a project that compiles. Whether it works depends on three things the codemod can't see: your database, your custom step-control logic, and your message-rendering layer. This page maps the gaps. The patterns the codemod handles, the patterns it doesn't, and the specific shapes of engineering work that account for the 2-6 week migration estimates published by teams who've done it.

What the codemod does handle

The syntactic renames are clean and complete:
  • parametersinputSchema on tool() definitions
  • maxSteps: NstopWhen: stepCountIs(N)
  • mimeTypemediaType on file content blocks
  • Import paths from ai/react and ai/rsc consolidate into @ai-sdk/react and @ai-sdk/rsc
  • experimental_StreamData removed in favor of structured data parts
  • Provider package paths updated (@ai-sdk/openai, @ai-sdk/anthropic)
For a clean v4 project — meaning no persisted messages, no custom step logic, no complex tool-result rendering — running the codemod and fixing the type errors it surfaces is a one-day job.

Gap 1: persisted DB messages

The biggest gap, and the one nobody warns about cleanly in the migration docs. v4 shape: ``ts { id: 'msg_1', role: 'assistant', content: 'I called the search tool and found...' } ` v5 shape: `ts { id: 'msg_1', role: 'assistant', parts: [ { type: 'text', text: 'I called the search tool and found...' }, { type: 'tool-call', toolCallId: 'call_1', toolName: 'search', args: { q: 'foo' } }, { type: 'tool-result', toolCallId: 'call_1', result: { ... } }, ] } ` A row written in v4 shape can't be passed directly to v5's useChat hook or to convertToCoreMessages. The codemod doesn't migrate rows because it doesn't reach into the database.

The three migration patterns

Pattern A: lazy migration on read. Keep v4 rows in place. Add a transform function in the data layer that converts on load:
`ts function v4ToV5(row: V4Message): UIMessage { return { id: row.id, role: row.role, parts: [{ type: 'text', text: row.content }], // tool calls in v4 were a separate table; merge here }; } ` Write the new v5 shape on every save. Over time the table fills with v5-shape rows and the transform is only hit for legacy rows. Lowest production disruption, requires the transform to be exactly right. Pattern B: one-shot script. SQL or application-level migration that converts every row in one pass: `ts for await (const row of db.messages.iterator()) { const v5 = v4ToV5(row); await db.messages.update(row.id, { parts: v5.parts, content: null }); } ` Simplest mental model, requires a maintenance window or careful online migration. The right choice for staging environments and for production tables under 1M rows where a few minutes of downtime is acceptable. Pattern C: dual-write transition. Write both shapes for a period, swap reads to v5, retire v4 column. Most complex; lowest production risk. The right choice for high-traffic production tables where Pattern A's transform-on-read is too hot. Tool calls are the part of the transform that surprises teams. v4 stored tool calls and tool results either in a separate column or in a custom JSON shape; v5 expects them as parts array entries with specific type values. The transform has to know your v4 schema specifically.

Gap 2: stopWhen logic

The codemod converts
maxSteps: 5 to stopWhen: stepCountIs(5) cleanly. It can't convert custom logic. The patterns that need hand-migration: Dynamic step limits. v4 code that computed maxSteps based on user tier or session state: `ts // v4 const maxSteps = user.isPro ? 20 : 5; streamText({ ..., maxSteps }); // v5 streamText({ ..., stopWhen: stepCountIs(user.isPro ? 20 : 5), }); ` The codemod doesn't trace the variable back to know it's dynamic; it leaves a stopWhen: stepCountIs(maxSteps) that may or may not type-check depending on how maxSteps was declared. Output-based short-circuit. v4 code that watched tool outputs and stopped early: `ts // v4 — usually implemented as a wrapper let shouldStop = false; const result = await streamText({ ..., onStepFinish: (step) => { if (step.toolResults?.some(r => r.error)) shouldStop = true; }, maxSteps: shouldStop ? 1 : 5, // hack }); // v5 — first-class stopWhen: ({ steps }) => steps[steps.length - 1].toolResults?.some(r => r.error) ?? false, ` This is a cleaner shape, but the codemod can't infer your intent. It leaves the onStepFinish and the maxSteps in place, and you re-author as a single stopWhen callable. Cost-cap logic. Teams who tracked accumulated tokens and stopped at a budget threshold: `ts stopWhen: ({ steps }) => { const totalTokens = steps.reduce((sum, s) => sum + (s.usage?.totalTokens ?? 0), 0); return totalTokens > MAX_TOKENS_PER_REQUEST; } ` Same story. The pattern is clean in v5; the migration is by hand.

Gap 3: UIMessage parts rendering

v4's frontend rendered
message.content directly as a string. v5's UIMessage forces you to walk the parts array and render each part by type: `tsx {message.parts.map((part, i) => { switch (part.type) { case 'text': return

{part.text}

; case 'tool-call': return ; case 'tool-result': return ; case 'reasoning': return {part.text}; case 'file': return ; default: return null; } })}
` The codemod doesn't touch JSX rendering logic. Every component that displayed a message in v4 needs updating to walk the parts array. For teams with rich tool-call UIs (sidebars showing tool invocations, expandable reasoning blocks), this is the largest single chunk of frontend work in the migration. The compensation is that the new shape is better — tool calls are first-class, reasoning blocks are first-class, file attachments are first-class. v4 forced teams to embed all of this in string content with custom delimiters. v5 makes it structured. The migration cost is real and the resulting code is cleaner.

A realistic timeline

For a project with persisted DB messages, custom step logic, and a non-trivial frontend:
  • Day 1-2: Run the codemod, fix immediate type errors, get the project compiling.
  • Day 3-5: Write the v4-to-v5 transform. Test against fixtures. Decide migration pattern (A/B/C).
  • Week 2: Migrate stopWhen logic by hand. Test agent loops against historical traces if available.
  • Week 2-3: Frontend parts-rendering pass. Most teams find 8-20 components to update.
  • Week 3-4: End-to-end testing. Production parity validation against the old version.
  • Week 4-6: Migrate DB rows (if using Pattern B or C) or roll out lazy migration (Pattern A).
The 2-6 week range covered in third-party post-mortems (BrainGrid, jhakim.com) reflects this fan-out. Teams without persisted messages and without custom stopping logic ship in days; teams with both spend the full six weeks.

What's in v6 that v5 didn't have

v6 makes Server Actions the primary streaming surface. Existing
/api/chat routes continue to work — v6 is additive on that axis. The v6 migration from v5 is much smaller than the v4 → v5 jump:
  • useChat API surface stays largely compatible
  • The useUIMessage` hook simplifies parts handling
  • Server Actions become the canonical pattern in the docs (REST routes still supported)
  • Better caching primitives surface in the provider options layer
New projects in 2026 should target v6 + Server Actions directly. Existing v5 projects can upgrade incrementally without the upheaval of the v4 jump.

What to read next

Sources

Frequently asked

Does the official codemod cover the v4 → v5 migration?
Partially. It handles the syntactic renames — parameters → inputSchema on tool definitions, maxSteps → stopWhen, mimeType → mediaType on file content, and the import-path changes. It does not handle three things that account for the bulk of migration cost: persisted DB messages in the old shape, custom stopWhen logic that replaces the maxSteps integer with a callable, and the UIMessage parts array which replaces the v4 content: string. Teams with no DB-persisted message history and simple maxSteps usage can ship the migration in a day. Teams with either of those two patterns face 2-6 weeks of engineering work depending on schema complexity.
What changed about persisted messages?
v4 stored messages as { role, content: string }. v5 introduces UIMessage with a parts array: { role, parts: [{ type: 'text', text }, { type: 'tool-call', toolCallId, args }, ...] }. The new shape is more expressive (tool calls, file attachments, reasoning blocks all become parts), but it's structurally incompatible with v4. Rows you wrote on v4 can't be passed to v5's hooks without transformation. The codemod doesn't migrate database rows because it can't see them — they live in your Postgres/SQLite/Mongo, not in the source tree.
What's the right migration pattern for DB messages?
Three options, in order of engineering cost. (1) Lazy migration on read — keep v4 rows in the DB, transform to UIMessage shape in the hook that loads them, write back in v5 shape on next save. Lowest disruption, requires a stable transform function. (2) Big-bang migration — write a script that converts every row in one pass. Highest disruption, simplest mental model. (3) Dual-write during transition — write v4 + v5 shapes for a transition window, swap reads to v5, retire v4 column. Most complex but lowest production risk. Most teams pick (1) for production traffic and (2) for staging fixtures.
How does stopWhen replace maxSteps?
v4's maxSteps was an integer — 'stop after N tool-calling steps.' v5's stopWhen is a callable that receives the step list and returns boolean. The codemod converts maxSteps: 5 to stopWhen: stepCountIs(5), which is a built-in helper. What it can't convert is custom logic — teams who computed maxSteps dynamically based on user tier, who short-circuited on specific tool outputs, or who tracked accumulated cost. Those become first-class stopWhen functions and need rewriting by hand.
Does v6 break anything else?
v6 introduces Server Actions as the primary streaming surface, replacing the /api/chat route pattern. Existing /api/chat routes continue to work — v6 is additive on that axis, not a breaking change. The migration cost from v5 to v6 is much lower than v4 to v5; the v5 jump is the painful one. New projects in 2026 should go straight to v6 + Server Actions.
Is this migration worth doing now?
If you're on v4 and have working production code, you have time — v4 isn't sunset and the SDK team has indicated extended support. But v4 doesn't get the new providers, the new caching surface, the structured-output improvements, or the agent loop primitives. New features land on v5/v6 only. The cost-of-delay shows up as 'we can't use prompt caching cleanly because we're on v4,' not as a hard sunset date.

Related topics