Next.js API routes vs route handlers: when to use each

By Nayan Kyada · · 7 min read

Part of Next.js Performance

Next.js API routes vs route handlers is one of those migrations that looks trivial in the docs but bites you on caching semantics and runtime defaults. Both surface HTTP endpoints inside a Next.js project, but they sit in fundamentally different mental models — one is a Node.js request/response wrapper, the other is built on the Web Fetch API and plays by App Router rules.

What each one actually is

Pages Router API routes live at pages/api/**/*.ts. They receive a NextApiRequest (a thin wrapper over Node's IncomingMessage) and a NextApiResponse. They run on Node.js by default (Edge API Routes exist, but use a different, Web-standard handler signature). They are never statically analysed for caching by the framework — every request hits your serverless function.

App Router route handlers live at app/**/route.ts. They receive a standard Request and return a standard Response. Because they are Web-standard, they can run on the Node.js runtime or the Edge runtime. They are not cached by default; a GET handler can opt into being prerendered/cached.

Side-by-side feature table

FeaturePages API routeApp Router route handler
File locationpages/api/slug.tsapp/api/slug/route.ts
Request typeNextApiRequestRequest (Web Fetch)
Response typeNextApiResponseResponse (Web Fetch)
Edge runtimeYes, as Edge API Routes (Web Request/Response signature)Yes (export const runtime = 'edge')
Static cachingNeverOpt-in — GET handlers aren't cached by default but can be
Streaming responseres.write() by handNative ReadableStream / streamText
Cookiesreq.cookiescookies() from next/headers
Draft modereq.previewdraftMode() from next/headers
Route segment configexport const config onlydynamic, revalidate, runtime exports (or use cache with Cache Components)
Colocation with UI routesNo — separate pages/api treeYes — anywhere in app/, though not in the same segment as a page.tsx

The same endpoint written both ways

Here is a JSON endpoint that returns a list of posts from a Sanity CDN query. First, the Pages Router version:

// pages/api/posts.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { client } from '@/sanity/client'
 
type Post = { _id: string; title: string; slug: string }
 
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Post[]>
) {
  if (req.method !== 'GET') {
    res.status(405).end()
    return
  }
 
  const posts = await client.fetch<Post[]>(
    `*[_type == "post"]{ _id, title, "slug": slug.current }`
  )
 
  // Manual cache header — framework does nothing for you here
  res.setHeader('Cache-Control', 's-maxage=60, stale-while-revalidate=300')
  res.status(200).json(posts)
}

Now the App Router route handler doing the same job, with ISR-style revalidation baked in:

// app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { client } from '@/sanity/client'
 
export const revalidate = 60 // ISR: revalidate every 60 seconds
 
type Post = { _id: string; title: string; slug: string }
 
export async function GET() {
  const posts = await client.fetch<Post[]>(
    `*[_type == "post"]{ _id, title, "slug": slug.current }`,
    {},
    { next: { revalidate: 60 } } // per-fetch revalidation
  )
 
  return NextResponse.json(posts)
}
 
export async function POST(request: Request) {
  const body = await request.json()
  // handle webhook or form submission
  return new Response(null, { status: 204 })
}

The route handler version is shorter and the caching is declarative. The revalidate export opts this GET handler into being cached and revalidated like an ISR page — no manual Cache-Control header arithmetic needed. (If you've enabled Cache Components, the segment config exports don't apply; use use cache with cacheLife in a helper function instead.)

Caching semantics: where people get burned

Caching defaults changed in Next.js 15: route handlers and fetch() calls are not cached by default anymore. The way people get burned now is the opposite direction — and with older guides. If you copy a pattern from a Next.js 13/14 tutorial, you may be relying on caching that no longer happens (and hitting Sanity on every request), or, if you add export const dynamic = 'force-static' or revalidate to a GET handler, a GET /api/posts you expected to be live will serve a cached response. In development you won't notice either way, so verify caching behaviour against a production build.

The safe defaults (without Cache Components):

  • Leave caching off (the default) if the endpoint must always be live (think auth-gated data, cart totals).
  • Set export const revalidate = N for ISR-style background refresh.
  • Set export const dynamic = 'force-static' if the response is truly static and you want it baked at build time.

Pages API routes have no framework-level caching. You either set Cache-Control yourself or you get no caching. That simplicity is also why they're still fine for low-traffic internal tooling — no surprises.

Streaming

Streaming from a Pages API route is clunky. You drop down to res.writeHead() / res.write() and manage the stream by hand. It works, but Next.js itself recommends Route Handlers for streaming.

Route handlers get first-class streaming through the Web Streams API:

// app/api/stream/route.ts
export const runtime = 'edge'
 
export async function GET() {
  const stream = new ReadableStream({
    async start(controller) {
      for (const chunk of ['one', 'two', 'three']) {
        controller.enqueue(new TextEncoder().encode(chunk + '\n'))
        await new Promise(r => setTimeout(r, 200))
      }
      controller.close()
    },
  })
 
  return new Response(stream, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  })
}

This is how AI text-generation endpoints (OpenAI, Vercel AI SDK's streamText) work in App Router projects. That pattern doesn't port cleanly to Pages Router API routes.

Edge runtime

Exporting export const runtime = 'edge' from a route handler moves execution to Vercel's edge network (or any compatible edge provider). Cold starts are typically much shorter than a Node.js serverless function. The trade-off: no full Node.js API, no fs, no native modules. If your handler only calls external APIs or reads from environment variables, edge can be a cheap performance win.

Pages API routes can also run at the edge as Edge API Routes, but you then write them against Web Request/Response anyway — at which point a route handler is the more natural home.

Migration path

There is no breaking change in moving from Pages to App Router for API endpoints. Both can coexist in the same project during a migration. The mechanical steps:

  1. Create app/api/<same-path>/route.ts.
  2. Replace NextApiRequest / NextApiResponse with Request / Response.
  3. Replace req.method === 'POST' branching with named exports (GET, POST, PATCH, DELETE).
  4. Replace req.cookies and res.setHeader with cookies() from next/headers and NextResponse helpers.
  5. Decide on caching intent — handlers are uncached by default; add revalidate or dynamic = 'force-static' only if you want caching.
  6. Delete the old pages/api file.

The one gotcha: middleware (renamed to proxy.ts in Next.js 16) that reads req.nextUrl and rewrites to /api/... paths will keep working because the URL shape is identical. Proxy is shared by both routers, so it doesn't need to change as part of the move.

When to stay on Pages API routes

  • You are deep in a Pages Router codebase with no near-term App Router migration budget.
  • You need a quick internal endpoint and do not want to think about caching semantics.
  • You rely on a Node.js library that cannot run at the edge and the Pages Router handler is already tested.

When to use route handlers

  • Any new Next.js project started on the App Router.
  • You need edge runtime for low-latency endpoints.
  • You want ISR caching for API responses (common with Sanity webhooks + on-demand revalidation).
  • You are building a streaming endpoint for AI, live data, or server-sent events.
  • You want an endpoint organised alongside the routes it serves rather than in a separate pages/api tree (for form submissions from your own UI, consider Server Actions instead).

The Web Fetch API surface is more verbose in some places — constructing headers manually, parsing formData() — but you get standards compliance in return, which means the same code runs on Deno, Cloudflare Workers, and any edge runtime that implements WinterCG. That portability matters more the longer a codebase lives.