Next.js Route Handlers: Replace pages/api with route.ts
If you're still writing pages/api/... files in a new Next.js project, stop. Route Handlers are the App Router's replacement, and they're meaningfully better — typed, streaming-capable, and built on the same Web platform APIs that Cloudflare.
Next.js Route Handlers: Replace pages/api with route.ts
If you're still writing pages/api/... files in a new Next.js project, stop. Route Handlers are the App Router's replacement, and they're meaningfully better — typed, streaming-capable, and built on the same Web platform APIs that Cloudflare Workers and Bun use.
This guide covers everything you need to move from pages/api to app/api without getting burned by the caching defaults that changed in Next.js 15 or the params async change that catches everyone once.
Why the Switch Matters
pages/api handlers were built around Node.js's IncomingMessage and ServerResponse objects — Express-flavored, tightly coupled to Node's HTTP module. They work. They just haven't aged well.
Route Handlers use the Fetch API — the native Request and Response objects. Code you write today transfers to edge runtimes without a rewrite.
The two routers still coexist in Next.js 16, so pages/api keeps working and you can move one endpoint at a time rather than doing a big-bang migration. But new capabilities land on Route Handlers, not on pages/api, so the direction of travel is one-way.
The Basic Shape
Route Handlers live in route.ts files inside the app/ directory. The file exports named functions matching HTTP method names:
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const users = await db.user.findMany()
return NextResponse.json(users)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}Compare that to the old pattern:
// pages/api/users.ts — the old way
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
res.json(users)
} else if (req.method === 'POST') {
res.status(201).json(user)
}
}The new version eliminates the method-dispatch switch. Each HTTP verb is a separate exported function. A request with a method you didn't export comes back 405 Method Not Allowed.
Supported exports: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. One exception to the 405 rule: if you don't export OPTIONS, Next.js implements it for you and fills in the Allow header from the methods you did export.
Reading Request Data
This is where most people stumble on the migration. There's no req.body, req.query, or req.cookies — you work directly with the Request object:
export async function POST(request: NextRequest) {
// JSON body
const body = await request.json()
// Form data
const form = await request.formData()
const email = form.get('email') as string
// Raw text (useful for webhook signature verification)
const rawBody = await request.text()
// Query parameters
const { searchParams } = new URL(request.url)
const page = Number(searchParams.get('page') ?? '1')
const limit = Number(searchParams.get('limit') ?? '20')
// Headers
const authHeader = request.headers.get('authorization')
return NextResponse.json({ ok: true })
}For cookies and headers, Next.js provides server-side helpers that also work in Server Components and Server Actions:
import { cookies, headers } from 'next/headers'
export async function GET() {
const cookieStore = await cookies() // async in Next.js 15
const token = cookieStore.get('session')?.value
const headersList = await headers()
const userAgent = headersList.get('user-agent')
return NextResponse.json({ token, userAgent })
}cookies(), headers() and draftMode() became async in Next.js 15, and in Next.js 16 the synchronous access path is gone entirely — awaiting them is mandatory, not stylistic. Same story for params and searchParams. If you're still on 14 they're synchronous, and an extra await is harmless there, so write the async form now.
Dynamic Segments
Dynamic route parameters use the same folder naming as pages, but you access them through the second argument:
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
interface RouteContext {
params: Promise<{ id: string }> // Promise in Next.js 15
}
export async function GET(
request: NextRequest,
{ params }: RouteContext
) {
const { id } = await params
const post = await db.post.findUnique({ where: { id } })
if (!post) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(post)
}
export async function PATCH(
request: NextRequest,
{ params }: RouteContext
) {
const { id } = await params
const body = await request.json()
const updated = await db.post.update({ where: { id }, data: body })
return NextResponse.json(updated)
}
export async function DELETE(
request: NextRequest,
{ params }: RouteContext
) {
const { id } = await params
await db.post.delete({ where: { id } })
return new Response(null, { status: 204 })
}That's full CRUD for a single resource: three handlers, one file, zero method-dispatch conditionals.
You can skip the hand-written RouteContext interface if you want: Next.js generates a global RouteContext<'/api/posts/[id]'> helper during next dev, next build or next typegen, which derives the params type from the route literal so a renamed folder becomes a type error instead of a runtime undefined.
Catch-all segments work the same way — just expect an array:
// app/api/files/[...path]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
// /api/files/docs/intro → path = ['docs', 'intro']
}Caching Behavior (The Part That Bites Everyone)
This is where bugs appear in production.
Next.js 14: GET handlers were cached by default. Call an external API without disabling cache, and you'd serve stale data until redeployment.
Next.js 15: The default flipped. GET handlers are uncached (dynamic) by default.
The config exports that control caching live at the top of your route file:
// app/api/public-stats/route.ts
// Cache the response, revalidate every 5 minutes
export const revalidate = 300
export async function GET() {
const stats = await fetchExpensiveAggregation()
return NextResponse.json(stats)
}// app/api/user/me/route.ts
// Never cache — always run the handler
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
const session = await getSession(request)
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.json(session.user)
}// Static JSON at build time — use when the data genuinely never changes
export const dynamic = 'force-static'
export async function GET() {
return NextResponse.json({ version: '1.0', buildTime: process.env.BUILD_TIME })
}You don't have to take this on faith — the build output tells you. In an app built with Next.js 16.1.6, the revalidate = 300 route above is listed as ○ (Static) with Revalidate 5m, the force-static route as ○ (Static), and every handler with no caching config as ƒ (Dynamic):
Route (app) Revalidate Expire
├ ƒ /api/users
├ ○ /api/public-stats 5m 1y
├ ○ /api/static-info
└ ƒ /api/user/medynamic, revalidate and fetchCache belong to the caching model that predates Cache Components. If you switch a Next.js 16 app to cacheComponents: true in next.config.ts, the build rejects those segment configs outright — Route segment config "revalidate" is not compatible with nextConfig.cacheComponents. Please remove it., and the same for dynamic and runtime — and caching comes from the use cache directive plus cacheLife/cacheTag instead — GET handlers then follow the same prerender model as pages. Everything in this section assumes the default, Cache-Components-off setup.
There cannot be a route.ts and a page.tsx at the same URL segment. app/dashboard/page.tsx and app/dashboard/route.ts is an error. Move the route handler to app/api/dashboard/route.ts or a different path segment.
Route Handler Lifecycle
One naming note on that first box: Next.js 16 renamed middleware.ts to proxy.ts (and the exported middleware function to proxy). The old filename still works, but it's deprecated and slated for removal.
Error Handling
Route Handlers don't catch thrown errors — an unhandled throw returns a 500 with no body in production. Wrap handlers explicitly:
// lib/route-utils.ts
import { NextRequest, NextResponse } from 'next/server'
import { ZodError, flattenError } from 'zod'
type RouteHandler = (
request: NextRequest,
context: { params: Promise<Record<string, string>> }
) => Promise<Response>
export function withErrorHandling(handler: RouteHandler): RouteHandler {
return async (request, context) => {
try {
return await handler(request, context)
} catch (error) {
console.error('[Route Handler Error]', { url: request.url, error })
if (error instanceof ZodError) {
return NextResponse.json(
{ error: 'Validation failed', details: flattenError(error) },
{ status: 422 }
)
}
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
}
}
}// app/api/posts/route.ts
import { PostCreateSchema } from '@/lib/validators/post'
import { withErrorHandling } from '@/lib/route-utils'
export const POST = withErrorHandling(async (request) => {
const body = PostCreateSchema.parse(await request.json())
const post = await db.post.create({ data: body })
return NextResponse.json(post, { status: 201 })
})Zod validation errors surface as structured 422 responses. Everything else becomes 500. Add more instanceof branches as your error taxonomy grows.
On Zod 4, prefer the standalone flattenError(error) over the older error.flatten() method — the method still exists but is marked deprecated, and both produce the same { formErrors, fieldErrors } shape.
Auth Pattern
// lib/with-auth.ts
import { NextRequest, NextResponse } from 'next/server'
import { getToken } from 'next-auth/jwt'
import type { JWT } from 'next-auth/jwt'
type AuthedHandler = (
request: NextRequest,
context: any,
token: JWT
) => Promise<Response>
export function withAuth(handler: AuthedHandler) {
return async (request: NextRequest, context: any) => {
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
})
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return handler(request, context, token)
}
}
// app/api/profile/route.ts
export const GET = withAuth(async (request, context, token) => {
const profile = await db.user.findUnique({ where: { id: token.sub } })
return NextResponse.json(profile)
})The JWT type comes from next-auth/jwt — import it, or the wrapper won't compile. On Auth.js v5 the secret is read from AUTH_SECRET (v4 used NEXTAUTH_SECRET), and getToken will fall back to the environment if you omit the secret option entirely.
CORS for Public APIs
Route Handlers don't add CORS headers automatically. Handle it explicitly:
// app/api/public/route.ts
const allowedOrigins = ['https://yourdomain.com', 'https://partner.com']
function corsHeaders(origin: string | null) {
const allowed = origin && allowedOrigins.includes(origin) ? origin : allowedOrigins[0]
return {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
// the response body depends on the request Origin — say so, or a CDN
// will happily serve one origin's response to another
Vary: 'Origin',
}
}
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get('origin')
return new Response(null, { status: 204, headers: corsHeaders(origin) })
}
export async function GET(request: NextRequest) {
const origin = request.headers.get('origin')
const data = await fetchPublicData()
return NextResponse.json(data, { headers: corsHeaders(origin) })
}Streaming Responses
Route Handlers support streaming via ReadableStream. The most common use case is Server-Sent Events for real-time updates:
// app/api/events/route.ts
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const send = (data: object) =>
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
for await (const event of watchJobQueue()) {
if (request.signal.aborted) break
send(event)
}
controller.close()
},
cancel() {
// Client disconnected
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}The request.signal.aborted check is important — without it, you'll keep processing events after the client disconnects.
Setting Cookies in the Response
export async function POST(request: NextRequest) {
const body = await request.json()
const session = await createSession(body.credentials)
const response = NextResponse.json({ ok: true })
response.cookies.set('session', session.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return response
}Edge Runtime
Add one export to run at the edge:
// app/api/geo/route.ts
export const runtime = 'edge'
export async function GET(request: NextRequest) {
// NextRequest.geo and .ip were removed in Next.js 15 — location comes
// from the host's request headers now, not from the request object.
const country = request.headers.get('x-vercel-ip-country') ?? 'unknown'
const city = request.headers.get('x-vercel-ip-city') ?? 'unknown'
return NextResponse.json({ country, city })
}If you're migrating older code: request.geo and request.ip no longer exist on NextRequest. They were removed in Next.js 15 because those values come from your hosting provider, and request.geo?.country is now a compile error — I confirmed it against Next.js 16.1.6: Property 'geo' does not exist on type 'NextRequest'. On Vercel, the typed replacement is geolocation(request) and ipAddress(request) from @vercel/functions; the official codemod installs that package for you. Reading the headers directly, as above, keeps the handler dependency-free.
Edge handlers deploy to Vercel's global network (or Cloudflare Workers). They're fast for geolocation, A/B token checks, and lightweight proxy logic. Two constraints worth knowing: no Node.js APIs — no fs, no crypto.createHash (use SubtleCrypto instead), no native add-ons — and runtime: 'edge' is not supported once you turn on Cache Components.
When NOT to Use Route Handlers
Route Handlers are the wrong tool when the caller is your own React component.
| Situation | Use This |
|---|---|
| Webhook from Stripe or GitHub | Route Handler |
| Public REST API for a mobile app | Route Handler |
| OAuth callback redirect | Route Handler |
| Server-Sent Events or streaming | Route Handler |
| Proxy to a third-party API | Route Handler |
| Form submission from your UI | Server Action |
| Database write from a component | Server Action |
| Optimistic UI update | Server Action |
| Any mutation only your app calls | Server Action |
A Server Action is not free of the network — invoking one still sends a request to the server, keyed by an encrypted action ID. What you save is the endpoint: no URL to design, document, version, or keep backwards-compatible, and no client-side fetch to write.
What you don't get is privacy. The Next.js data security guide is blunt about it: an exported Server Action "is reachable via a direct POST request, not just through your application's UI." Encrypted action IDs and dead-code elimination raise the bar, but authentication and authorization still belong inside every action, exactly as they do inside a Route Handler.
So the dividing line is the audience, not the security model. If something outside your app needs a stable URL — a webhook sender, a mobile client, an OAuth provider — you need a Route Handler. If the only caller is your own UI, a Route Handler is an endpoint you have to maintain for no one.
The Takeaway
Three things to internalize before you write your first Route Handler:
- Named exports per method — one function per HTTP verb, no method switching
- Web APIs, not Node APIs —
request.json()notreq.body,new Response()notres.json() - Caching is explicit from v15 onward — nothing is cached unless you add
revalidateorforce-static, and under Cache Components you opt in withuse cacheinstead
The integration boundary is where Route Handlers earn their place: webhooks, external REST APIs, OAuth callbacks, streaming. Everything your own UI calls directly is better served by Server Actions.
Comments (0)
No comments yet. Be the first to share your thoughts!