Route Handlers: the mental model shift
In the Pages Router, your API routes lived in /pages/api/ and exported a default function.
In the App Router, they live anywhere in the /app directory inside a file called route.ts —
and instead of a default export, you export named functions corresponding to HTTP methods:
GET, POST, PUT, PATCH, DELETE.
This is not just a naming change. It's a fundamentally different model.
Route Handlers run in the Edge Runtime by default (when deployed to Vercel),
they use the standard Web Request and Response objects instead of Node's req/res,
and they can be co-located directly with the UI components they serve.
That last part is the most useful thing about them.
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const page = Number(searchParams.get('page')) || 1
const limit = Number(searchParams.get('limit')) || 20
const posts = await db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' }
})
return NextResponse.json({ posts, page, limit })
}
export async function POST(request: NextRequest) {
const body = await request.json()
// validation, creation logic here
return NextResponse.json({ success: true }, { status: 201 })
}
Consistent error handling — the thing most codebases get wrong
The default approach to error handling in Next.js apps I've seen in the wild is: wrap things in try/catch, return a 500 with a message, and hope for the best. This works until you need to debug a production error and your logs say "Something went wrong" with no indication of which endpoint, what input, or what actually went wrong.
Build a consistent error response structure from day one. Every error response from every Route Handler should have the same shape. Every caught error should be logged with context. And different error types should map to different HTTP status codes — a validation error is a 400, an auth failure is a 401, a missing resource is a 404, and only a genuinely unexpected exception should ever be a 500.
import { NextResponse } from 'next/server'
import { ZodError } from 'zod'
export function apiSuccess<T>(data: T, status = 200) {
return NextResponse.json({ success: true, data }, { status })
}
export function apiError(message: string, status: number, details?: unknown) {
return NextResponse.json(
{ success: false, error: message, details },
{ status }
)
}
export function handleRouteError(error: unknown) {
if (error instanceof ZodError) {
return apiError('Validation failed', 400, error.flatten())
}
if (error instanceof AuthError) {
return apiError('Unauthorised', 401)
}
console.error('[API Error]', error)
return apiError('Internal server error', 500)
}
Now every Route Handler uses the same pattern. The response shape is always predictable. The client always knows what to expect. And when something does fail, it fails with enough information to diagnose it.
Input validation with Zod
Never trust the body of a POST request. Not even from your own frontend. Network interception, browser extensions, direct API calls from scripts, a future you who forgot what the schema was — all of these can send you unexpected shapes. Zod validation at the Route Handler boundary is not optional in a production codebase.
import { z } from 'zod'
import { apiSuccess, handleRouteError } from '@/lib/api-response'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer')
})
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validated = CreateUserSchema.parse(body) // throws ZodError if invalid
const user = await db.user.create({ data: validated })
return apiSuccess(user, 201)
} catch (error) {
return handleRouteError(error) // ZodError → 400, others → 500
}
}
Middleware: where authentication lives
A common mistake: putting authentication checks inside individual Route Handlers. This means auth logic is scattered, inconsistently implemented, and every new Route Handler you add needs you to remember to add the auth check. Someone will forget.
Authentication belongs in middleware. The middleware.ts file at the root of your project
runs before every matching request — before the Route Handler even executes.
This is where you validate JWTs, check sessions, and redirect unauthenticated users.
import { NextRequest, NextResponse } from 'next/server'
import { verifyJWT } from '@/lib/auth'
const PROTECTED_PATHS = ['/api/admin', '/api/users', '/dashboard']
export async function middleware(request: NextRequest) {
const isProtected = PROTECTED_PATHS.some(
path => request.nextUrl.pathname.startsWith(path)
)
if (!isProtected) return NextResponse.next()
const token = request.cookies.get('auth-token')?.value
if (!token) {
return NextResponse.json({ error: 'Unauthorised' }, { status: 401 })
}
const payload = await verifyJWT(token)
if (!payload) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
}
// Pass user data to route handlers via headers
const response = NextResponse.next()
response.headers.set('x-user-id', payload.userId)
response.headers.set('x-user-role', payload.role)
return response
}
export const config = {
matcher: ['/api/:path*', '/dashboard/:path*']
}
Caching Route Handlers correctly
In the App Router, GET Route Handlers are cached by default when they don't use dynamic functions.
This is useful and surprising in equal measure.
Useful because a GET endpoint that returns static-ish data can be served from cache without hitting your database.
Surprising because you might expect a database call to always run and find that it doesn't.
export const dynamic = 'force-dynamic'
export async function GET() {
// always runs fresh
const data = await db.getLatest()
return NextResponse.json(data)
}
// This may be served from cache
// if nothing signals dynamic usage
export async function GET() {
const data = await db.getLatest()
return NextResponse.json(data)
}
Dynamic route segments
When your Route Handler needs a path parameter — like /api/posts/[id] —
the segment is passed as the second argument to the handler function.
type RouteParams = { params: { id: string } }
export async function GET(
request: NextRequest,
{ params }: RouteParams
) {
const post = await db.post.findUnique({
where: { id: params.id }
})
if (!post) {
return apiError('Post not found', 404)
}
return apiSuccess(post)
}
Three patterns I use in almost every production codebase
A higher-order function that wraps Route Handlers with role-based auth checks. Reduces boilerplate and makes it impossible to accidentally ship an unprotected endpoint.
Helper functions like apiSuccess() and apiError() that enforce consistent response shapes. Your TypeScript types on the client can then be derived from the response schema rather than maintained separately.
Define your Zod schemas in a shared /lib/schemas directory. Import them in both the Route Handler (for runtime validation) and in your client-side forms (for UI validation). One source of truth for what your API accepts.
What this unlocks
The patterns in this post aren't interesting individually. Consistent error handling, Zod validation, middleware auth — these are table stakes. What's interesting is what you can do when they're all in place.
A codebase with consistent API patterns is dramatically easier to extend, easier to onboard new developers into, and easier to debug in production. It's the difference between a codebase that feels like it was built by one person with a plan and one that feels like six people each made different decisions independently.
If you're building something bigger than a side project, these patterns pay for themselves within the first month of active development. Set them up before you write your first Route Handler, not after you have thirty of them.
More on the development side in my TypeScript patterns for scale post. And if you need a custom Next.js application built with this kind of architectural rigour, my custom development service is where I do exactly that.