React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

apiroute-handlersnextrequestnextresponseresthttp

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 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().
  • Dynamic params are now async in Next.js 15+. The second argument is { params: Promise<{ ... }> }, and you must await params before accessing values.
  • Route Handlers are cached by default for GET requests with no dynamic input. Use request.nextUrl.searchParams, cookies(), or headers() to opt into dynamic rendering automatically.
  • A route.ts file in the same directory as a page.tsx will 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() returns Promise<any>. Cast or validate the result with Zod for type safety.
  • Use NextRequest (not Request) to access .nextUrl, .cookies, and other Next.js extensions.

Gotchas

  1. 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".
  2. 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.
  3. request.json() throws on empty or malformed bodies. Wrap it in try/catch for safety.
  4. 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.
  5. Edge Runtime Route Handlers cannot use Node.js APIs like fs, Buffer, or crypto (use globalThis.crypto instead).

Alternatives

ApproachProsCons
Route HandlersNative, zero-config, colocatedNo built-in validation or middleware chain
Server ActionsNo API route needed, form-friendlyNot RESTful, harder to call externally
tRPCEnd-to-end type safetyExtra dependency, learning curve
Express/Fastify custom serverFull middleware ecosystemLoses automatic static optimization
Hono on Edge RuntimeLightweight, middleware chainNot 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 = 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.
  • The credit deduction uses 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.
  • 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 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.

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"
  • upsert creates the record on first use or increments on subsequent uses. No separate "does this exist?" query
  • The composite unique constraint sessionId_ipAddress_toolName ensures one counter per user per tool per day
  • userId || ipAddress as sessionId handles both authenticated and anonymous users
  • limit === -1 means unlimited (admin tier). Still tracks usage for analytics but never denies
  • Day boundary check: updated.lastUsedAt < today detects 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, and OPTIONS.
  • Each is a named export from a route.ts file inside the app directory.
  • Only export the methods your endpoint supports.
Why are GET Route Handlers cached by default?
  • Next.js statically caches GET handlers with no dynamic input at build time.
  • Accessing searchParams, cookies(), or headers() 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?
  • NextRequest extends Request with .nextUrl (parsed URL), .cookies, .geo, and .ip.
  • Use NextRequest to access these Next.js-specific conveniences.
  • The standard Request works 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?
  • updateMany with where: { credits: { gt: 0 } } checks and decrements in a single query.
  • Two simultaneous requests cannot both read credits: 1 and both proceed.
  • updated.count === 0 means 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 typed
How do you handle CORS in Route Handlers?
  • Export an OPTIONS function that returns CORS headers with a 204 status.
  • Set Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-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.