API Route Handlers
Recipe
Build type-safe REST API endpoints using Next.js 15+ App Router Route Handlers with NextRequest, NextResponse, request validation, and proper HTTP status codes.
Working Example
Basic CRUD Route Handler
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
type Post = {
id: string;
title: string;
content: string;
createdAt: string;
};
// GET /api/posts
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "10", 10);
const posts = await db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: "desc" },
});
return NextResponse.json({ posts, page, limit });
}
// POST /api/posts
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.title || !body.content) {
return NextResponse.json(
{ error: "title and content are required" },
{ status: 400 }
);
}
const post = await db.post.create({
data: { title: body.title, content: body.content },
});
return NextResponse.json(post, { status: 201 });
}Dynamic Route Parameter
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
type Params = { params: Promise<{ id: string }> };
// GET /api/posts/:id
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id } });
if (!post) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
return NextResponse.json(post);
}
// PATCH /api/posts/:id
export async function PATCH(request: NextRequest, { params }: Params) {
const { id } = await params;
const body = await request.json();
const post = await db.post.update({
where: { id },
data: body,
});
return NextResponse.json(post);
}
// DELETE /api/posts/:id
export async function DELETE(request: NextRequest, { params }: Params) {
const { id } = await params;
await db.post.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}Route Handler with Headers and Cookies
// app/api/auth/me/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySession } from "@/lib/auth";
export async function GET(request: NextRequest) {
const token = request.cookies.get("session-token")?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await verifySession(token);
if (!session) {
return NextResponse.json({ error: "Invalid session" }, { status: 401 });
}
const response = NextResponse.json({ user: session });
response.headers.set("X-Request-Id", crypto.randomUUID());
return response;
}Deep Dive
How It Works
- Route Handlers are defined by exporting HTTP method functions (
GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS) from aroute.tsfile inside theappdirectory. NextRequestextends the standardRequestwith convenience properties:.nextUrlfor parsed URL,.cookiesfor cookie access, and.geo/.ipon edge deployments.NextResponseextendsResponsewith static helpers:.json(),.redirect(), and.rewrite().- Dynamic params are now async in Next.js 15+. The second argument is
{ params: Promise<{ ... }> }, and you mustawait paramsbefore accessing values. - Route Handlers are cached by default for
GETrequests with no dynamic input. Userequest.nextUrl.searchParams,cookies(), orheaders()to opt into dynamic rendering automatically. - A
route.tsfile in the same directory as apage.tsxwill conflict. Route Handlers and pages cannot coexist at the same route segment.
Variations
Streaming Response:
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (const chunk of ["Hello", " ", "World"]) {
controller.enqueue(encoder.encode(chunk));
await new Promise((r) => setTimeout(r, 100));
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain" },
});
}Form Data Handling:
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Save buffer to storage...
return NextResponse.json({ filename: file.name, size: file.size });
}CORS Headers:
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}TypeScript Notes
- The params type changed in Next.js 15: use
Promise<{ id: string }>instead of the old synchronous{ id: string }. request.json()returnsPromise<any>. Cast or validate the result with Zod for type safety.- Use
NextRequest(notRequest) to access.nextUrl,.cookies, and other Next.js extensions.
Gotchas
GETRoute Handlers with no dynamic input are statically cached at build time. AccesssearchParams,cookies(), orheaders()to make them dynamic, or exportconst dynamic = "force-dynamic".route.tsandpage.tsxcannot share the same directory. The Route Handler will shadow the page. Place API routes underapp/api/to avoid conflicts.request.json()throws on empty or malformed bodies. Wrap it in try/catch for safety.- Returning
new Response(null, { status: 204 })is required for no-content responses.NextResponse.json(null, { status: 204 })will send an empty JSON body, not a true no-content response. - Edge Runtime Route Handlers cannot use Node.js APIs like
fs,Buffer, orcrypto(useglobalThis.cryptoinstead).
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Route Handlers | Native, zero-config, colocated | No built-in validation or middleware chain |
| Server Actions | No API route needed, form-friendly | Not RESTful, harder to call externally |
| tRPC | End-to-end type safety | Extra dependency, learning curve |
| Express/Fastify custom server | Full middleware ecosystem | Loses automatic static optimization |
| Hono on Edge Runtime | Lightweight, middleware chain | Not officially supported by Next.js |
Real-World Example
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: API route with security composition and atomic usage tracking
// File: app/api/generate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withCsrfProtection } from '@/lib/csrf';
import { withRateLimit } from '@/lib/rate-limit';
import { prisma } from '@/lib/prisma';
import { getSession } from '@/lib/auth';
export const maxDuration = 60; // Vercel function timeout in seconds
async function handler(request: NextRequest) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { prompt, projectId } = body;
// Atomic usage tracking: deduct credit before the operation
const updated = await prisma.user.updateMany({
where: {
id: session.userId,
credits: { gt: 0 },
},
data: {
credits: { decrement: 1 },
},
});
if (updated.count === 0) {
return NextResponse.json({ error: 'No credits remaining' }, { status: 402 });
}
try {
const result = await generateContent(prompt, projectId);
return NextResponse.json({ result });
} catch (error) {
// Rollback: restore the credit on failure
await prisma.user.update({
where: { id: session.userId },
data: { credits: { increment: 1 } },
});
return NextResponse.json({ error: 'Generation failed' }, { status: 500 });
}
}
// Security middleware composition: CSRF check wraps rate limiter wraps handler
export const POST = withCsrfProtection(withRateLimit(handler));What this demonstrates in production:
withCsrfProtection(withRateLimit(handler))composes security middleware as higher-order functions. The CSRF check runs first. If it passes, the rate limiter runs. If that passes, the handler runs. This composition pattern avoids deeply nested middleware chains.export const maxDuration = 60sets the Vercel serverless function timeout. The default is 10 seconds, which is too short for AI content generation. This config is specific to Vercel deployments.- The credit deduction uses
updateManywith awhere: { credits: { gt: 0 } }condition. This is an atomic operation: the credit is only deducted if the user has credits remaining. It prevents race conditions where two simultaneous requests could both readcredits: 1and both proceed. updated.count === 0means either the user was not found or had zero credits. This single check handles both cases without a separate read query.- The rollback in the catch block increments the credit back if the generation fails. This ensures users are not charged for failed operations. In a high-concurrency scenario, consider using a database transaction instead.
- Exporting
POSTas the composed function (nothandlerdirectly) ensures every POST request goes through both security layers. Named exports likeGET,POST,PUTmap directly to HTTP methods in Next.js route handlers.
Real-World Example: Atomic Rate Limiting
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: TOCTOU-safe rate limiting with Prisma upsert
// File: src/lib/tools/usage-limits.ts
export async function checkAndIncrementUsage(
toolId: string,
userId: string | null,
ipAddress: string,
userTier: UserTier
): Promise<{ allowed: boolean; limit: number; used: number; resetsAt: string }> {
const limit = await getToolUsageLimitAsync(toolId, userTier);
if (limit === -1) {
await incrementToolUsage(toolId, userId, ipAddress);
return { allowed: true, limit: -1, used: 0, resetsAt: new Date(Date.now() + 86400000).toISOString() };
}
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
const sessionId = userId || ipAddress;
// Atomic upsert: increment first, then check
const updated = await prisma.toolUsage.upsert({
where: {
sessionId_ipAddress_toolName: { sessionId, ipAddress, toolName: toolId },
},
create: { toolName: toolId, userId, sessionId, ipAddress, usageCount: 1, lastUsedAt: new Date() },
update: { usageCount: { increment: 1 }, lastUsedAt: new Date() },
});
// Day boundary reset
if (updated.lastUsedAt < today) {
const reset = await prisma.toolUsage.update({
where: { id: updated.id },
data: { usageCount: 1, lastUsedAt: new Date() },
});
return { allowed: true, limit, used: reset.usageCount, resetsAt: tomorrow.toISOString() };
}
// Over limit? Rollback the increment
if (updated.usageCount > limit) {
await prisma.toolUsage.update({
where: { id: updated.id },
data: { usageCount: { decrement: 1 } },
});
return { allowed: false, limit, used: updated.usageCount - 1, resetsAt: tomorrow.toISOString() };
}
return { allowed: true, limit, used: updated.usageCount, resetsAt: tomorrow.toISOString() };
}What this demonstrates in production:
- TOCTOU prevention: Instead of "read count, check limit, then increment" (which has a race window), this does "increment first, then check if over limit, rollback if needed"
upsertcreates the record on first use or increments on subsequent uses. No separate "does this exist?" query- The composite unique constraint
sessionId_ipAddress_toolNameensures one counter per user per tool per day userId || ipAddressas sessionId handles both authenticated and anonymous userslimit === -1means unlimited (admin tier). Still tracks usage for analytics but never denies- Day boundary check:
updated.lastUsedAt < todaydetects stale records from yesterday and resets to 1 - The rollback
{ decrement: 1 }undoes the optimistic increment when the limit is exceeded
FAQs
What HTTP methods can a Route Handler export?
GET,POST,PUT,PATCH,DELETE,HEAD, andOPTIONS.- Each is a named export from a
route.tsfile inside theappdirectory. - Only export the methods your endpoint supports.
Why are GET Route Handlers cached by default?
- Next.js statically caches
GEThandlers with no dynamic input at build time. - Accessing
searchParams,cookies(), orheaders()opts into dynamic rendering automatically. - Use
export const dynamic = "force-dynamic"to force dynamic behavior explicitly.
What is the difference between NextRequest and the standard Request?
NextRequestextendsRequestwith.nextUrl(parsed URL),.cookies,.geo, and.ip.- Use
NextRequestto access these Next.js-specific conveniences. - The standard
Requestworks but lacks these helpers.
Why are dynamic params a Promise in Next.js 15+?
type Params = { params: Promise<{ id: string }> };
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
}- In Next.js 15+, params are async and must be awaited before use.
- This is a breaking change from earlier versions where params were synchronous.
Gotcha: Can route.ts and page.tsx coexist in the same directory?
- No. A Route Handler will shadow the page at the same route segment.
- Place API routes under
app/api/to avoid conflicts with page routes.
How do you return a proper 204 No Content response?
return new NextResponse(null, { status: 204 });- Do not use
NextResponse.json(null, { status: 204 })as it sends an empty JSON body. - Use
new NextResponse(null, { status: 204 })for a true no-content response.
Gotcha: What happens if request.json() receives an empty or malformed body?
- It throws an error at runtime.
- Always wrap
request.json()in a try/catch block. - Return a 400 status with an error message on parse failure.
How does the security middleware composition pattern work in the real-world example?
withCsrfProtection(withRateLimit(handler))chains higher-order functions.- CSRF check runs first; if it passes, rate limiting runs; if that passes, the handler runs.
- This avoids deeply nested middleware and keeps each concern isolated.
What is atomic credit deduction and why does it prevent race conditions?
updateManywithwhere: { credits: { gt: 0 } }checks and decrements in a single query.- Two simultaneous requests cannot both read
credits: 1and both proceed. updated.count === 0means the deduction failed (no credits or user not found).
How would you type the JSON body from request.json() safely in TypeScript?
import { z } from "zod";
const bodySchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
});
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten() },
{ status: 400 }
);
}
// parsed.data is fully typedHow do you handle CORS in Route Handlers?
- Export an
OPTIONSfunction that returns CORS headers with a 204 status. - Set
Access-Control-Allow-Origin,Access-Control-Allow-Methods, andAccess-Control-Allow-Headers. - For non-preflight requests, add CORS headers to the actual response as well.
What is the TOCTOU problem in rate limiting and how does the upsert pattern solve it?
- TOCTOU (Time-of-Check-Time-of-Use): reading the count, checking the limit, then incrementing has a race window.
- The upsert pattern increments first, then checks if over the limit, and rolls back if needed.
- This ensures no gap between the check and the update.
Related
- Authentication - protecting API routes with session checks
- Error Handling - consistent error responses
- Environment Variables - accessing secrets in Route Handlers
- Next.js Route Handlers Docs