Next.js Route Handlers: REST API Patterns for App Router
Next.js App Router route handlers let you build REST APIs without a separate backend. Here's how to structure them properly without common pitfalls.
What Route Handlers Actually Are in App Router
Honestly, the Pages Router API routes were fine — but they carried a lot of conceptual baggage. You'd create a file under pages/api/, export a single function, and handle every HTTP method inside one giant if-else chain. It worked, but it never felt like a real API.
App Router changes this. Route handlers live in app/ directories, co-located with your UI. A file named route.ts inside app/api/users/route.ts becomes your endpoint. You export named functions — GET, POST, PUT, DELETE, PATCH — and Next.js 14.2+ routes requests to the right one automatically.
The underlying implementation uses the Web Fetch API. Request and Response are the native browser globals, not Node.js req/res objects. That's a meaningful shift. It means your handlers are closer to standard and more portable — you can test them without spinning up a server.
Basic Route Handler Structure with TypeScript
Here's a minimal but complete route handler for a users endpoint. This covers GET and POST in the same file, typed correctly for Next.js 14+.
import { NextRequest, NextResponse } from 'next/server'
type User = {
id: string
name: string
email: string
}
// GET /api/users
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') ?? '1', 10)
const limit = parseInt(searchParams.get('limit') ?? '20', 10)
try {
const users: User[] = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
})
return NextResponse.json(
{ data: users, page, limit },
{ status: 200 }
)
} catch (err) {
console.error('[GET /api/users]', err)
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
)
}
}
// POST /api/users
export async function POST(request: NextRequest) {
try {
const body = await request.json()
if (!body.name || !body.email) {
return NextResponse.json(
{ error: 'name and email are required' },
{ status: 400 }
)
}
const user = await db.user.create({
data: { name: body.name, email: body.email },
})
return NextResponse.json({ data: user }, { status: 201 })
} catch (err) {
console.error('[POST /api/users]', err)
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
)
}
}A few things worth calling out. The NextRequest type extends the standard Request — it adds helpers like .nextUrl for parsed URL access. You don't have to use it, but it saves you from manually constructing new URL(request.url) repeatedly. Also note the explicit status codes. Skipping them defaults to 200, which gives you silent failures on POST when you actually want 201.
Dynamic Segments and Typed Route Params
Dynamic segments work the same as page routes. A file at app/api/users/[id]/route.ts captures the id segment. The second argument to your handler is a context object with a params property.
import { NextRequest, NextResponse } from 'next/server'
type RouteContext = {
params: Promise<{ id: string }>
}
export async function GET(
request: NextRequest,
{ params }: RouteContext
) {
const { id } = await params
const user = await db.user.findUnique({ where: { id } })
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({ data: user })
}
export async function DELETE(
request: NextRequest,
{ params }: RouteContext
) {
const { id } = await params
await db.user.delete({ where: { id } })
return new Response(null, { status: 204 })
}Note that in Next.js 15, params became a Promise — you need to await it. If you're still on 14.x, it's a plain object. Worth checking your next version in package.json before you get confused by a type error that makes no sense at first glance.
For the DELETE returning a 204, I'm using the bare Response constructor with a null body. NextResponse.json() always sets a JSON content-type header, which is technically wrong for 204 No Content responses. Small detail, but it matters if you're integrating with strict clients.
Middleware-Style Patterns: Auth and Validation
What's the cleanest way to add authentication to route handlers without repeating yourself? The Pages Router had a withApiAuth wrapper pattern that's still applicable here. You can write higher-order functions that wrap your handlers.
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from '@/lib/auth'
type Handler = (
req: NextRequest,
ctx: { params: Promise<Record<string, string>> },
userId: string
) => Promise<Response>
export function withAuth(handler: Handler) {
return async (
req: NextRequest,
ctx: { params: Promise<Record<string, string>> }
) => {
const authHeader = req.headers.get('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Missing or invalid authorization header' },
{ status: 401 }
)
}
const token = authHeader.slice(7)
try {
const { userId } = await verifyToken(token)
return handler(req, ctx, userId)
} catch {
return NextResponse.json(
{ error: 'Invalid token' },
{ status: 401 }
)
}
}
}
// Usage:
export const GET = withAuth(async (req, ctx, userId) => {
const profile = await db.user.findUnique({ where: { id: userId } })
return NextResponse.json({ data: profile })
})This pattern keeps the auth logic in one place and makes the actual handler function focused. You can stack multiple wrappers — withAuth(withRateLimit(handler)) — though at that point you might want to evaluate a proper middleware library. If you're building something complex and you want UI feedback for auth states, check out how toast notifications integrate with auth flows for the UI side of error handling.
Caching Behavior and the Force-Dynamic Trap
Route handlers with GET are cached by default in Next.js 14 when they don't use dynamic APIs. That means if your GET handler doesn't read cookies, headers, or search params, Next.js might serve a cached response. This trips up a lot of people the first time they build a route handler and wonder why their data isn't updating.
To opt out of caching, you have two options. Export export const dynamic = 'force-dynamic' at the top of your route file, or access any dynamic API inside the handler — request.headers, cookies(), request.url with query params. The moment you read from those, Next.js marks the route as dynamic automatically.
For POST, PUT, PATCH, and DELETE — don't worry. These are never cached. The caching behavior only applies to GET. Also note that revalidatePath and revalidateTag work from route handlers, so you can build mutation endpoints that also invalidate related cached pages in a single request.
If you're thinking about how this fits into your broader architecture alongside things like React performance patterns, caching at the route handler level is a separate layer from React's cache — they interact but don't replace each other.
Handling File Uploads and FormData
JSON isn't always the transport. File uploads require multipart/form-data, and route handlers handle this natively via the FormData API. No multer, no busboy — just await request.formData().
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File | null
const title = formData.get('title') as string | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// Max 4MB for Vercel edge functions
const MAX_SIZE = 4 * 1024 * 1024
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: 'File exceeds 4MB limit' },
{ status: 413 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Upload to your storage provider
const url = await uploadToStorage(buffer, {
filename: file.name,
contentType: file.type,
})
return NextResponse.json({ url, title }, { status: 201 })
}The 4MB limit is real on Vercel's serverless functions — worth enforcing in your handler rather than letting the platform kill the request with an opaque error. If you need larger uploads, you'll want to generate a pre-signed URL from your storage provider and have the client upload directly, bypassing your Next.js server entirely.
CORS, Custom Headers, and Response Utilities
If you're building an API that third-party clients will call, you need CORS headers. Route handlers don't add these automatically. The standard approach is to handle the OPTIONS preflight request explicitly.
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'https://your-frontend-domain.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
export async function OPTIONS() {
return NextResponse.json({}, { headers: CORS_HEADERS })
}
export async function GET(request: NextRequest) {
const data = await fetchData()
return NextResponse.json(
{ data },
{
status: 200,
headers: {
...CORS_HEADERS,
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
'X-Content-Type-Options': 'nosniff',
},
}
)
}You can also set headers via Next.js middleware (middleware.ts at the project root) to apply CORS globally without touching every route file. For public APIs, that's usually the better call. For internal APIs consumed only by your own frontend, skip CORS entirely — same-origin requests don't need it.
On the TypeScript side, if you're building typed API clients for these endpoints, combining route handler types with a library like zod for request validation makes the whole thing much safer. That pairs well with React Hook Form patterns on the frontend when you're building forms that hit these endpoints.
Structuring a Real API: Folder Layout and Shared Logic
As your API grows, file organization matters more than people initially think. A flat app/api/ directory with a dozen route files becomes hard to navigate. A pattern that works well is grouping by resource, with shared logic in a sibling lib/ directory.
app/
api/
users/
route.ts # GET /api/users, POST /api/users
[id]/
route.ts # GET, PUT, DELETE /api/users/:id
avatar/
route.ts # POST /api/users/:id/avatar
posts/
route.ts
[slug]/
route.ts
lib/
api/
auth.ts # withAuth wrapper
errors.ts # typed error responses
validate.ts # zod schema validators
pagination.ts # shared pagination logicThe lib/api/ directory is key. Keeping error response shapes consistent across all routes means your frontend can handle errors predictably. A single apiError(message: string, status: number) utility function that always returns the same { error: string } shape is far better than each handler doing something slightly different.
If you're pulling in TypeScript patterns from your component library into your API layer as well, you'll find that sharing Zod schemas between your route handler validation and your frontend form validation is genuinely worth setting up early. One source of truth for what a User or Post looks like across your whole stack.
FAQ
For most SaaS and startup projects, route handlers are enough. They run on serverless functions, support databases via Prisma or Drizzle, and handle auth without any issue. Where you'd want a separate backend is if you need persistent connections (WebSockets), CPU-intensive processing, or you're deploying to infrastructure where Next.js serverless doesn't fit — like a single EC2 instance handling high throughput.
Next.js 14 caches GET route handlers by default when they don't use dynamic APIs. Add export const dynamic = 'force-dynamic' at the top of your route file to disable caching, or access request.headers or request.url inside the handler to trigger dynamic rendering automatically.
Import cookies from next/headers and call it inside your handler: const cookieStore = await cookies() (it's async in Next.js 15). Then read values with cookieStore.get('session-token')?.value. Accessing cookies automatically marks the route as dynamic, so caching won't interfere.
Server Actions are called directly from React components and work with useFormState/useActionState — they're tightly coupled to React's rendering model. Route handlers are plain HTTP endpoints that any client can call, including mobile apps, external services, or scripts. For form submissions within your own Next.js app, Server Actions are often simpler. For anything that needs to be a real HTTP API, use route handlers.
Return a ReadableStream wrapped in a Response. Set the Content-Type header to text/event-stream for SSE, or text/plain for raw streaming. You can create the stream with new ReadableStream({ start(controller) { ... } }) and push chunks with controller.enqueue(encoder.encode(chunk)). Next.js passes this through without buffering.
On Vercel, the request body limit for serverless functions is 4.5MB. On self-hosted Next.js with Node.js, there's no hard framework limit but your server resources apply. For file uploads larger than a few megabytes, generate a pre-signed URL from S3 or Cloudflare R2 and upload directly from the browser — don't route the file through your Next.js server.