Server Actions are purpose-built for internal UI mutations and form processing alongside React Server Components. However, modern web architectures constantly require public, standard RESTful interfaces accessible to external systems: mobile applications, microservices, third-party webhooks (Stripe, GitHub, Slack), and dynamic asset generation (RSS feeds, XML sitemaps, open-graph dynamic images).
To build isolated HTTP endpoints in the Next.js App Router, developers use Route Handlers. Replacing legacy API Routes from the Pages Router, Route Handlers provide granular control over the network transport layer: HTTP methods, response status codes, headers, cookie mutations, and binary streaming.
Route Handlers execute exclusively on the server in an isolated context. This guarantees secure database communication, prevents secret environment variables from leaking, and avoids adding execution overhead to the client JavaScript bundle.
Routing in the Next.js App Router is strictly mapped to the file system:
page.tsx files handle user-facing interfaces and return React Server Components (HTML and RSC Flight Payload).route.ts files handle programmatic data access and return native Web Response instances (JSON, binary streams, XML).[!IMPORTANT] Route Conflict Rule: A
route.tsfile cannot coexist at the same directory segment as apage.tsxfile. A single URL path cannot simultaneously serve an HTML document and a raw API response. Placing/users/page.tsxand/users/route.tsin the same directory results in a build-timeRoute conflict error.
Inside a route.ts file, you export named asynchronous functions corresponding directly to standard HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS.
Next.js builds directly on web standard Request and Response interfaces, enhancing them with the NextRequest and NextResponse abstractions.
Next.js matches the incoming URL path and resolves the route.ts file tree.
If both route.ts and page.tsx exist in the same directory, the build halts with a conflict error.
The request routes to the configured environment: Node.js runtime (default) or Edge V8 runtime.
Route parameters are injected into the handler context as an unresolved Promise.
Next.js verifies the incoming HTTP method (GET, POST, OPTIONS) against exported functions.
If the requested method is not exported from the module, Next.js automatically returns 405 Method Not Allowed.
Headers and cookies are read asynchronously, validating auth tokens and signatures.
For webhooks, the raw payload is read via await req.text() to prevent HMAC hash corruption.
Database queries run or streaming tokens are piped through a ReadableStream.
The handler returns a Response or NextResponse object with custom headers and status codes.
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { users } from '@/lib/schema';
// Handler for GET requests
export async function GET(request: NextRequest) {
// Extract query parameters via NextRequest nextUrl helper
const searchParams = request.nextUrl.searchParams;
const limit = Math.min(Number(searchParams.get('limit') ?? '10'), 50);
const userList = await db.query.users.findMany({
limit,
});
return NextResponse.json(userList, {
status: 200,
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120',
},
});
}
// Handler for POST requests
export async function POST(request: NextRequest) {
try {
// Safe async parsing of JSON request body
const body: unknown = await request.json();
if (!body || typeof body !== 'object' || !('name' in body)) {
return NextResponse.json(
{ error: 'Invalid payload structure' },
{ status: 400 }
);
}
const newUser = await db.insert(users).values(body).returning();
return NextResponse.json(newUser[0], { status: 201 });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Internal Server Error';
return NextResponse.json({ error: message }, { status: 500 });
}
}In Next.js 15 and 16, the params context object is an asynchronous Promise. Direct synchronous property access (params.id) causes build warnings and breaks type safety.
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { users } from '@/lib/schema';
import { eq } from 'drizzle-orm';
interface RouteContext {
params: Promise<{
id: string;
}>;
}
export async function DELETE(
_request: NextRequest,
{ params }: RouteContext
) {
// Required async resolution of route parameters in Next.js 15+
const { id: userId } = await params;
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 400 });
}
await db.delete(users).where(eq(users.id, userId));
// Return empty 204 No Content response
return new Response(null, { status: 204 });
}Next.js 15+ requires awaiting cookies() and headers() calls from next/headers:
import { cookies, headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// 1. Asynchronous server header reading
const headerStore = await headers();
const authorization = headerStore.get('authorization');
// 2. Asynchronous cookie access
const cookieStore = await cookies();
const theme = cookieStore.get('theme')?.value ?? 'dark';
// 3. Client IP lookup via NextRequest
const clientIp = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
// Compose response and set a new session cookie
const response = NextResponse.json({
authorized: Boolean(authorization),
theme,
clientIp,
});
response.cookies.set('session_active', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
return response;
}Why is calling await req.json() strictly forbidden before verifying a Stripe webhook signature in a Route Handler, requiring await req.text() instead?
When an API is called by browsers on external domains, CORS must be handled explicitly with an OPTIONS handler.
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'https://dashboard.example.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
// Browser preflight check prior to sending actual requests
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}
export async function GET() {
return Response.json(
{ status: 'healthy', timestamp: Date.now() },
{
status: 200,
headers: CORS_HEADERS,
}
);
}Route Handlers natively support the Web Streams API, enabling real-time LLM token streaming or progressive large file downloads.
export async function GET() {
const encoder = new TextEncoder();
// Initialize a native ReadableStream
const stream = new ReadableStream({
async start(controller) {
const chunks = ['Streaming ', 'data ', 'generated ', 'in real ', 'time.\n'];
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
// Simulate streaming token latency
await new Promise((resolve) => setTimeout(resolve, 300));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}To validate a webhook signature, never parse the request body as JSON before verification. Any slight discrepancy in formatting invalidates the cryptographic HMAC hash.
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', {
apiVersion: '2025-02-24.acacia',
});
export async function POST(req: NextRequest) {
// HMAC verification requires the exact raw string representation
const payload = await req.text();
const headerStore = await headers();
const signature = headerStore.get('stripe-signature');
if (!signature) {
return new Response('Missing signature header', { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET ?? ''
);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : 'Unknown signature error';
return new Response(`Webhook signature verification failed: ${errMsg}`, { status: 400 });
}
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
// Process payment fulfillment logic...
}
return NextResponse.json({ received: true }, { status: 200 });
}A developer writes in a Server Component at app/dashboard/page.tsx: const res = await fetch('http://localhost:3000/api/metrics'). Why is this considered a severe architectural anti-pattern?
The incoming request body request.body is a native Locked Stream. Once you execute a body parsing method (e.g., await request.json()), the stream is consumed (bodyUsed = true).
Any subsequent attempt to read the body (such as inside a logging middleware or audit helper) throws TypeError: Body has already been consumed or is locked.
Solution: Store the parsed output in a local variable, or clone the request (req.clone()) before reading.
// ❌ ERROR: Attempting to consume the stream twice
export async function POST(req: Request) {
const text = await req.text();
const json = await req.json(); // Throws fatal TypeError: Body already consumed!
}
// ✅ CORRECT: Cache raw text in a variable and parse manually if needed
export async function POST(req: Request) {
const rawText = await req.text();
const parsedData = JSON.parse(rawText);
}In Next.js 13 and 14, route parameters were passed synchronously as { params: { id: string } }. In Next.js 15 and 16, params is strictly an asynchronous Promise. Accessing params.id without await params produces compiler warnings and breaks runtime safety.
Junior developers frequently create a route.ts endpoint simply to invoke fetch('http://localhost:3000/api/data') inside page.tsx.
This induces gratuitous network overhead (an HTTP loopback through the TCP stack), consumes server sockets, and degrades Time to First Byte (TTFB). When a Server Component needs data, encapsulate logic inside a Data Access Layer (DAL) and invoke it directly as a standard TypeScript function.