Skip to content

Migrating a Vercel AI SDK Route From the Pages Router to App Router

9 min read · updated August 11, 2026

The migration is not about file locations. A Pages API route is handed a Node response object and writes bytes into it; an App Router route handler returns a Response whose body is a stream. Once that lands, the rest of the change is mechanical.

The one difference that matters

In the Pages Router, an API route exports a default function that receives (req, res). Node has already parsed the body onto req.body, and you produce output by calling res.write and res.end. Streaming a model response means driving that write loop yourself, or handing the socket to a helper that does.

In the App Router, a file named route.ts exports a function per HTTP method — export async function POST(req: Request) — and it returns a web-standard Response. Nothing is parsed for you: you call await req.json() yourself. And streaming is not something you do, it is something the object you return already is, because a Response body can be a ReadableStream.

This is why the AI SDK’s modern surface fits the App Router so cleanly. The result object from a streaming call exposes methods that construct a Response with the correct body, content type and headers already set, so the last line of your handler is a single return. There is no place left for the write loop to go wrong, which removes an entire category of bug — the half-flushed response, the missing terminator, the connection left open.

What the old route looked like

The shape being migrated, in outline. The details vary by SDK generation, but the structure is always the same: parse, call, pipe into res.

// pages/api/chat.ts  — the shape you are leaving
export default async function handler(req, res) {
  const { messages } = req.body;              // parsed for you

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache, no-transform");
  res.setHeader("Connection", "keep-alive");

  const stream = await callTheModel(messages);
  for await (const chunk of stream) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`);
  }
  res.write("data: [DONE]\n\n");
  res.end();
}

Three things in that snippet are about to become somebody else’s problem: the headers, the framing, and the terminator. Note also what it does not do — handle the client disconnecting mid-stream, which in a Pages route means listening for a close event on the request and aborting the upstream call. That omission is extremely common and it is why abandoned streams keep billing.

The route handler

// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText, convertToModelMessages, type UIMessage } from "ai";

export const maxDuration = 30;

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: openai("gpt-4o"),
    system: "You are a concise support assistant.",
    messages: convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

Everything the old handler did by hand is inside that last line. The headers are set, the events are framed, the terminator is written, and the stream is tied to the request’s abort signal so a client that navigates away cancels the upstream call rather than leaving it running.

The names in this handler are the AI SDK 5 surface. The equivalent method in the previous major was spelled differently — the response constructors and several parameter names were renamed in the 5.0 release, and Vercel’s 5.0 migration guide lists the old and new names side by side. Check which major you are on before copying this; if the method does not exist, that is which version you have, not a mistake.

Doing the migration

  1. Create app/api/chat/route.ts while leaving pages/api/chat.ts in place. Two routes cannot answer the same path, so give the new one a different path for now — the client change is one string.
  2. Move the body parse. Replace req.body with await req.json() and type the result. This is where a migration usually fails first, silently, because req.body was an object and req.json() is a promise.
  3. Replace the default export with a named POST export. A route handler with a default export is not a route handler and the framework will not call it.
  4. Delete the header block, the write loop and the terminator, and return the SDK’s stream response instead. Delete them together; a handler that sets its own Content-Type and then returns a constructed Response has two answers to one question.
  5. Move the runtime and duration configuration. In the App Router these are module-level exports from the route file, such as export const runtime and export const maxDuration, and they do not carry over from the Pages config object.
  6. Point the client at the new path, exercise it end to end, then delete the old file. Confirm streaming with curl -N before you believe the browser: a proxy that buffers will make a working stream look broken and vice versa.
curl -N -X POST http://localhost:3000/api/chat \
  -H 'content-type: application/json' \
  -d '{"messages":[{"role":"user","parts":[{"type":"text","text":"hello"}]}]}'

Watch for the events arriving progressively rather than all at once at the end. If they arrive in one burst, something between you and the handler is buffering, and that something is usually a proxy or a compression middleware rather than your code.

The things that bite afterwards

  • Dynamic rendering. A route handler that does not read the request can be statically evaluated at build time. Reading the body makes it dynamic, which is what you want — but a handler that streams a constant will be cached, and you will spend an hour wondering why every user gets the same answer.
  • The platform timeout, not yours. maxDuration is capped by what your hosting plan permits. A long generation that works locally and truncates in production is usually this, and it shows up as a stream that stops mid-sentence with no error.
  • Client message shape. The messages your UI holds and the messages the model takes are different types in the current SDK, which is why the conversion function exists. Sending UI messages straight to the model, or storing model messages and rendering them, both fail in ways that look like data corruption.
  • Error responses in a stream. Once you have returned a 200 with a stream body, you cannot change your mind and return a 500. Errors that occur mid-generation have to be sent as part of the stream and handled by the client. Decide what that looks like before you ship, or your users will see a truncated answer with no indication that anything went wrong.
  • Node APIs on the edge runtime. If you set the runtime to edge, anything reaching for a Node built-in stops working. Migrate the route first, change the runtime second, so you only debug one thing at a time.

The error case deserves more than a bullet, because it changes your product rather than your code. In the Pages shape an upstream failure that happened before the first write could still be turned into a clean status code, and a lot of applications were quietly relying on that. Returning a stream moves the decision earlier: by the time the model call fails you have already committed to a 200 and a body. The usual resolution is two-sided. Keep the failures that occur before any token is produced — authentication, an unknown model, a malformed request — inside the handler where they can still be returned as a real status code, and make the client treat an explicit error part arriving mid-stream as a failed message rather than as the end of a successful one. Decide which failures sit on which side of that line before you migrate, because retrofitting it means changing the client as well as the route.