Next.js Route Handlers vs API Routes
Understand the difference between Pages Router API routes and App Router route handlers, including request, response, and runtime options.
What you'll learn
- ✓How API routes work in the Pages Router
- ✓How route handlers work in the App Router
- ✓When to choose the Node vs Edge runtime
- ✓Request and response object differences
- ✓Migration tips for existing API routes
Prerequisites
- •Comfortable with HTML and JavaScript
What and Why
Next.js exposes two ways to write server endpoints. The Pages Router has pages/api/*.ts files that export a default handler with Express-style req and res objects. The App Router replaces them with route handlers in app/**/route.ts that use the standard Request and Response from the Web Fetch API. Knowing the differences helps you write code that runs anywhere and avoids subtle bugs during migrations.
Mental Model
API routes are tied to Node.js and built around a mutable response object. You call res.status(200).json(data) to send a reply. Route handlers, in contrast, are pure functions that take a Request and return a Response. There is no implicit state on the response object. This functional style maps cleanly to the Edge runtime, service workers, and Cloudflare Workers.
Hands-on Example
A Pages Router API route looks like this.
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end();
const { name } = req.body;
res.status(200).json({ greeting: `Hello, ${name}` });
}
The App Router equivalent uses named exports per HTTP method and returns a Response.
// app/api/hello/route.ts
export async function POST(request: Request) {
const { name } = await request.json();
return Response.json({ greeting: `Hello, ${name}` });
}
Pages Router App Router
export default handler export POST(request)
req, res mutable Request in, Response out
Node only Node or Edge
body parsed by Next parse with request.json()
You can opt into the Edge runtime by exporting runtime.
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
export async function GET(request: Request) {
const url = new URL(request.url);
const q = url.searchParams.get('q');
return Response.json({ q, region: process.env.VERCEL_REGION });
}
Edge handlers start in milliseconds and run close to users, but they cannot use Node-only APIs like fs or most database drivers that rely on raw TCP. For long-running queries or file system access, stick with the default Node runtime.
Common Pitfalls
Many bugs come from assuming the App Router parses the body for you. It does not. If you forget await request.json(), the body remains a stream and you get undefined fields. The same applies to form data, where you must call await request.formData().
CORS behavior also differs. With API routes you set headers via res.setHeader. With route handlers you must include headers when constructing the Response. A common fix for preflight is to export an OPTIONS handler that returns the right Access-Control-Allow-* headers.
Cookies work differently. Use the cookies() helper from next/headers in route handlers instead of touching req.cookies directly. The helper integrates with caching so reading cookies opts your route into dynamic rendering.
Practical Tips
If you maintain both routers during a migration, share business logic in a plain function and call it from both adapters. The handler files become thin glue.
// lib/greet.ts
export async function greet(name: string) {
return { greeting: `Hello, ${name}` };
}
Set export const dynamic = 'force-static' for endpoints that produce the same response for everyone. Next.js then caches the result like a static asset, which is great for sitemaps and feed files.
Use Zod or Valibot to validate the parsed body. Both produce a clear error you can return as a 400 response. This is much safer than trusting request.json() to give you the shape you expect.
Stream large responses with ReadableStream and Response. You can flush partial output as soon as it is ready, which keeps time to first byte low.
Wrap-up
API routes and route handlers solve the same problem with different ergonomics. The App Router version is closer to web standards, runs on more runtimes, and composes well with the rest of the App Router. New projects should default to route handlers. Existing apps can migrate one endpoint at a time by sharing logic in a plain library function.
Related articles
- Next.js Next.js Caching Strategies Explained
Walk through the four caching layers in Next.js App Router and learn how to choose static, ISR, dynamic, or per-request fetch caching.
- Next.js Next.js Streaming and Suspense Boundaries
Learn how the App Router streams HTML over the wire and how Suspense boundaries control what users see while data loads.
- GraphQL GraphQL with Prisma Tutorial
A practical guide to wiring GraphQL on top of Prisma. Schema design, resolvers, the N+1 problem, batching, and the patterns that keep your API fast as it grows.
- Next.js Next.js Data Fetching Patterns: A Practical Guide
Compare the main Next.js data fetching strategies and learn when to use server components, route handlers, SWR, or static generation in your apps.