//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Build type-safe REST API endpoints using Next.js 15+ App Router Route Handlers with NextRequest, NextResponse, request validation, and proper HTTP status codes.
// 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 });
}// 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 });
}// 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;
}GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) from a route.ts file inside the app directory.NextRequest extends the standard Request with convenience properties: .nextUrl for parsed URL, .cookies for cookie access, and .geo / .ip on edge deployments.NextResponse extends Response with static helpers: .json(), .redirect(), and .rewrite().{ params: Promise<{ ... }> }, and you must await params before accessing values.GET requests with no dynamic input. Use request.nextUrl.searchParams, cookies(), or headers() to opt into dynamic rendering automatically.route.ts file in the same directory as a page.tsx will conflict. Route Handlers and pages cannot coexist at the same route segment.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",
},
});
}Promise<{ id: string }> instead of the old synchronous { id: string }.request.json() returns Promise<any>. Cast or validate the result with Zod for type safety.NextRequest (not Request) to access .nextUrl, .cookies, and other Next.js extensions.GET Route Handlers with no dynamic input are statically cached at build time. Access searchParams, cookies(), or headers() to make them dynamic, or export const dynamic = "force-dynamic".route.ts and page.tsx cannot share the same directory. The Route Handler will shadow the page. Place API routes under app/api/ to avoid conflicts.request.json() throws on empty or malformed bodies. Wrap it in try/catch for safety.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.fs, Buffer, or crypto (use globalThis.crypto instead).
| 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 |
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 = 60 sets 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.updateMany with a where: { 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 read credits: 1 and both proceed.updated.count === 0 means either the user was not found or had zero credits. This single check handles both cases without a separate read query.POST as the composed function (not handler directly) ensures every POST request goes through both security layers. Named exports like GET, POST, PUT map directly to HTTP methods in Next.js route handlers.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:
upsert creates the record on first use or increments on subsequent uses. No separate "does this exist?" querysessionId_ipAddress_toolName ensures one counter per user per tool per dayuserId || ipAddress as sessionId handles both authenticated and anonymous userslimit === -1 means unlimited (admin tier). Still tracks usage for analytics but never deniesupdated.lastUsedAt < today detects stale records from yesterday and resets to 1{ decrement: 1 } undoes the optimistic increment when the limit is exceededGET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS.route.ts file inside the app directory.GET handlers with no dynamic input at build time.searchParams, cookies(), or headers() opts into dynamic rendering automatically.export const dynamic = "force-dynamic" to force dynamic behavior explicitly.NextRequest extends Request with .nextUrl (parsed URL), .cookies, .geo, and .ip.NextRequest to access these Next.js-specific conveniences.Request works but lacks these helpers.type Params = { params: Promise<{ id: string }> };
export async function GET(request: NextRequest, { params }: Params) {
const { id } = await params;
}app/api/ to avoid conflicts with page routes.return new NextResponse(null, { status: 204 });NextResponse.json(null, { status: 204 }) as it sends an empty JSON body.new NextResponse(null, { status: 204 }) for a true no-content response.request.json() in a try/catch block.withCsrfProtection(withRateLimit(handler)) chains higher-order functions.updateMany with where: { credits: { gt: 0 } } checks and decrements in a single query.credits: 1 and both proceed.updated.count === 0 means the deduction failed (no credits or user not found).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 typedOPTIONS function that returns CORS headers with a 204 status.Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥