50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 19, 2026·7 min read

Next.js Route Handlers vs API Routes: The Complete Migration Guide

App Router killed pages/api. Here's everything you need to migrate your API routes to route handlers without breaking production.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Next.js Route Handlers vs API Routes: The Complete Migration Guide

We migrated three production apps from Pages Router API routes to App Router route handlers last year. Two went smoothly. One caused a 40-minute outage because we didn't understand how the new caching behavior worked. This guide is the thing we wish existed before we started.

If you're still on pages/api and wondering whether to migrate — or you've started the migration and hit weird behavior you can't explain — stick around. We're going to cover the actual differences that matter, the gotchas that will bite you, and the migration path that won't ruin your weekend.

What Actually Changed (And Why)

Pages Router API routes lived in pages/api and used a Node.js-style req/res API that felt familiar if you'd ever touched Express. You exported a default function, got a request and response object, and did your thing. Simple, predictable, slightly ugly.

App Router route handlers use the Web Platform's native Request and Response objects. Instead of pages/api/users.ts, you create app/api/users/route.ts and export named functions for each HTTP method. This is a meaningful shift — not just syntactic sugar. The Web APIs are more portable, work in edge environments, and Vercel's entire edge runtime is built around them.

// OLD: pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    res.status(200).json({ users: [] })
  } else if (req.method === 'POST') {
    const body = req.body
    res.status(201).json({ created: true })
  } else {
    res.setHeader('Allow', ['GET', 'POST'])
    res.status(405).end('Method Not Allowed')
  }
}

// NEW: app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  return NextResponse.json({ users: [] })
}

export async function POST(request: NextRequest) {
  const body = await request.json()
  return NextResponse.json({ created: true }, { status: 201 })
}
// No need to handle 405 — Next.js does it automatically for unexported methods

Immediately cleaner. Each HTTP verb gets its own exported function. No more if/else chains checking req.method. Next.js automatically returns 405 for any method you haven't exported. This alone makes the code easier to read and test.

The File Structure Migration

The mapping from pages/api to app/api is mostly mechanical. Here's the pattern:

  • pages/api/users.ts → app/api/users/route.ts
  • pages/api/users/[id].ts → app/api/users/[id]/route.ts
  • pages/api/auth/[...nextauth].ts → app/api/auth/[...nextauth]/route.ts
  • pages/api/webhooks/stripe.ts → app/api/webhooks/stripe/route.ts

Every API route becomes a directory containing a route.ts file. Dynamic segments work the same way — [id] still means dynamic segment, [...slug] still means catch-all. The naming convention just moved into the folder structure rather than the filename.

Dynamic params are handled differently though. In the old API routes, you'd grab them from req.query. In route handlers, they come in as a second argument:

// OLD: pages/api/users/[id].ts
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const { id } = req.query // id is string | string[]
  res.json({ userId: id })
}

// NEW: app/api/users/[id]/route.ts
type Params = { params: Promise<{ id: string }> }

export async function GET(request: NextRequest, { params }: Params) {
  const { id } = await params // Note: params is now a Promise in Next.js 15
  return NextResponse.json({ userId: id })
}

// For Next.js 14 and below, params wasn't a Promise:
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
  const { id } = params
  return NextResponse.json({ userId: id })
}
Next.js 15 made params a Promise. If you're migrating directly from Pages Router to Next.js 15 App Router, you need to await params. If you're on Next.js 14, you don't. Check your version before you copy-paste.

Reading Request Data Without Shooting Yourself

In Pages Router, Next.js auto-parsed the request body for you. JSON came in pre-parsed on req.body. In route handlers, you're working with the raw Web API Request object — you have to parse it yourself. This is where a lot of bugs hide during migrations.

export async function POST(request: NextRequest) {
  // JSON body
  const body = await request.json()

  // Form data
  const formData = await request.formData()
  const name = formData.get('name') as string

  // Plain text
  const text = await request.text()

  // Query params (same URL, different method)
  const searchParams = request.nextUrl.searchParams
  const page = searchParams.get('page') ?? '1'
  const limit = parseInt(searchParams.get('limit') ?? '20')

  // Headers
  const authHeader = request.headers.get('authorization')
  const contentType = request.headers.get('content-type')

  return NextResponse.json({ ok: true })
}

One thing that trips people up: you can only read the request body once. If you call request.json() and then try to call request.text(), you'll get an error because the stream has already been consumed. If you need the raw body for something like Stripe webhook signature verification, call request.text() first, then JSON.parse() it manually.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: NextRequest) {
  // Must read as text first for signature verification
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json(
      { error: 'Webhook signature verification failed' },
      { status: 400 }
    )
  }

  // Now you can parse the body manually if needed
  const data = JSON.parse(body)

  switch (event.type) {
    case 'checkout.session.completed':
      // handle it
      break
  }

  return NextResponse.json({ received: true })
}

The Caching Trap That Caused Our Outage

Here's the one that got us. Route handlers in the App Router can be cached by default — specifically GET handlers that don't use dynamic functions. If your GET handler doesn't use cookies, headers, or request.url in a dynamic way, Next.js might cache the response at build time and serve the same response to everyone forever.

We had a /api/config endpoint that returned some feature flags. After migrating to route handlers, it worked fine in development. In production, after deploying a flag change, some users kept getting the old values for hours. The response was cached at the CDN level because we hadn't opted out of caching.

// This GET handler MIGHT be cached in Next.js App Router
export async function GET() {
  const flags = await getFeatureFlags()
  return NextResponse.json(flags)
}

// To opt out of caching — pick one approach:

// Option 1: Export a dynamic config
export const dynamic = 'force-dynamic'

export async function GET() {
  const flags = await getFeatureFlags()
  return NextResponse.json(flags)
}

// Option 2: Use a dynamic function inside the handler
export async function GET(request: NextRequest) {
  const _ = request.headers.get('x-anything') // triggers dynamic rendering
  const flags = await getFeatureFlags()
  return NextResponse.json(flags)
}

// Option 3: Set cache headers explicitly
export async function GET() {
  const flags = await getFeatureFlags()
  return NextResponse.json(flags, {
    headers: {
      'Cache-Control': 'no-store, max-age=0',
    },
  })
}

Our recommendation: add export const dynamic = 'force-dynamic' to any route handler that returns user-specific data, reads from a database, or needs to be fresh on every request. It's better to be explicit here. The default caching behavior catches too many people off guard.

Middleware and Auth: Don't Break Your Protected Routes

If you were protecting API routes using middleware in the Pages Router, the good news is that Next.js middleware works the same way in the App Router. Your middleware.ts at the root is unchanged.

But if you were doing auth checks inside each API route function, the migration is a good time to clean that up. Route handlers can now access cookies much more cleanly:

import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  // Access cookies via next/headers (server-side)
  const cookieStore = await cookies()
  const sessionToken = cookieStore.get('session-token')

  if (!sessionToken) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const session = await validateSession(sessionToken.value)

  if (!session) {
    return NextResponse.json({ error: 'Invalid session' }, { status: 401 })
  }

  // Proceed with authenticated request
  const data = await getUserData(session.userId)
  return NextResponse.json(data)
}

// Or use request.cookies for a slightly different API:
export async function POST(request: NextRequest) {
  const token = request.cookies.get('session-token')?.value
  // same deal from here
}

One pattern we use across our apps: a withAuth higher-order function that wraps route handlers and injects the session. Saves you from copy-pasting auth checks everywhere.

// lib/with-auth.ts
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/session'

type AuthenticatedHandler = (
  request: NextRequest,
  context: { params: Promise<Record<string, string>>; session: Session }
) => Promise<NextResponse>

export function withAuth(handler: AuthenticatedHandler) {
  return async (
    request: NextRequest,
    context: { params: Promise<Record<string, string>> }
  ) => {
    const session = await getSession(request)

    if (!session) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
    }

    return handler(request, { ...context, session })
  }
}

// Usage in app/api/profile/route.ts
export const GET = withAuth(async (request, { session }) => {
  const profile = await getProfile(session.userId)
  return NextResponse.json(profile)
})

CORS, Error Handling, and the Stuff You Always Forget

CORS in Pages Router required either a package or manually setting headers in every response. Route handlers make this cleaner with the OPTIONS method export:

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}

export async function OPTIONS() {
  return new NextResponse(null, { status: 200, headers: corsHeaders })
}

export async function GET(request: NextRequest) {
  const data = await fetchData()
  return NextResponse.json(data, { headers: corsHeaders })
}

// If you need CORS everywhere, wrap it:
function withCors(response: NextResponse): NextResponse {
  Object.entries(corsHeaders).forEach(([key, value]) => {
    response.headers.set(key, value)
  })
  return response
}

For error handling, we wrap everything in try/catch and return consistent error shapes. The Web API Response lets you set status codes cleanly, but don't forget to actually catch your async errors — unhandled promise rejections in route handlers will give you a 500 with no useful body in production.

Always wrap your route handler logic in try/catch. A thrown error in a route handler gives the client a 500 with an empty body in production. You want to control that error shape.

The Migration Checklist

When we do this migration for a new project, we follow this order:

  • Audit all files in pages/api — list every endpoint and its HTTP methods
  • Create the parallel directory structure in app/api before moving anything
  • Migrate one route at a time, starting with the simplest GET handlers
  • Update all req.body references to await request.json() (or request.text() for webhooks)
  • Update all req.query references to request.nextUrl.searchParams or await params
  • Update res.status().json() to NextResponse.json() with the status as second arg option
  • Add export const dynamic = 'force-dynamic' to any handler that shouldn't be cached
  • Test auth flows — cookies() and session reading behaves differently
  • Verify webhook handlers still work (Stripe, GitHub, etc.) — test signature verification
  • Delete pages/api files only after confirming the new routes work in staging

We build all our starters at peal.dev with App Router route handlers from day one, so you get the modern patterns without having to migrate anything. But if you're mid-migration on an existing project, this checklist has saved us from the worst of the pain.

One more thing: you don't have to migrate everything at once. Pages Router and App Router can coexist in the same Next.js project. Your pages/api routes keep working while you migrate them incrementally to app/api. Don't let the scope of the migration stop you from starting — pick your simplest endpoint, migrate it, and ship it.

The Web API Request/Response model is the future. It runs on the edge, it's portable, and it's actually more predictable once you understand the caching behavior. The migration is worth it — just don't do it all in one deploy at 11pm on a Friday.
Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders