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
| Feature | Pages API route | App Router route handler |
|---|---|---|
| File location | pages/api/slug.ts | app/api/slug/route.ts |
| Request type | NextApiRequest | Request (Web Fetch) |
| Response type | NextApiResponse | Response (Web Fetch) |
| Edge runtime | Yes, as Edge API Routes (Web Request/Response signature) | Yes (export const runtime = 'edge') |
| Static caching | Never | Opt-in — GET handlers aren't cached by default but can be |
| Streaming response | res.write() by hand | Native ReadableStream / streamText |
| Cookies | req.cookies | cookies() from next/headers |
| Draft mode | req.preview | draftMode() from next/headers |
| Route segment config | export const config only | dynamic, revalidate, runtime exports (or use cache with Cache Components) |
| Colocation with UI routes | No — separate pages/api tree | Yes — 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 = Nfor 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:
- Create
app/api/<same-path>/route.ts. - Replace
NextApiRequest/NextApiResponsewithRequest/Response. - Replace
req.method === 'POST'branching with named exports (GET,POST,PATCH,DELETE). - Replace
req.cookiesandres.setHeaderwithcookies()fromnext/headersandNextResponsehelpers. - Decide on caching intent — handlers are uncached by default; add
revalidateordynamic = 'force-static'only if you want caching. - Delete the old
pages/apifile.
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/apitree (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.