API Routes
Route Handlers for building REST APIs, handling webhooks, streaming responses, and CORS in Next.js App Router
You are an expert in Next.js Route Handlers for building backend APIs, webhooks, and server endpoints within the App Router. ## Key Points - Use Route Handlers for external API consumers, webhooks, and streaming. For internal mutations from your own UI, prefer Server Actions. - Validate all incoming data with Zod or a similar schema library before processing. - Return appropriate HTTP status codes: 201 for creation, 204 for deletion, 400 for bad input, 401/403 for auth errors, 404 for missing resources. - Handle CORS explicitly when your API is consumed by third-party frontends. - Use `request.text()` (not `.json()`) for webhook signature verification to preserve the raw body. - Set `export const runtime = "edge"` for latency-sensitive endpoints that do not need Node.js APIs. - **`route.ts` and `page.tsx` conflict**: A directory cannot have both `route.ts` and `page.tsx`. The route handler takes priority and the page will not render. - **Reading the body twice**: `request.json()` consumes the body stream. If you need it for both validation and signature checking, read it once with `request.text()` and then `JSON.parse()`. - **GET caching**: A `GET` handler without dynamic input is cached by default in production. If it should be fresh every request, set `export const dynamic = "force-dynamic"`. - **Missing CORS preflight**: Browsers send an `OPTIONS` request before cross-origin `POST`/`PUT`/`DELETE`. If you do not handle it, the request fails silently.
skilldb get nextjs-skills/API RoutesFull skill: 346 linesAPI Routes (Route Handlers) — Next.js
You are an expert in Next.js Route Handlers for building backend APIs, webhooks, and server endpoints within the App Router.
Overview
Route Handlers replace the Pages Router pages/api convention. They are defined in route.ts files inside the app/ directory and support standard HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS). Route Handlers run on the server and can use the Node.js or Edge runtime.
Core Philosophy
Route Handlers are Next.js's answer to building server-side endpoints that live alongside your application code. The guiding principle is co-location with purpose: your API lives in the same repository, the same deployment, and the same routing tree as your pages, which eliminates the friction of maintaining a separate backend service for straightforward use cases like webhooks, third-party integrations, and public APIs.
The design philosophy favors web-standard primitives over framework abstractions. Route Handlers work with the Web Request and Response APIs, making your code portable and your knowledge transferable. When you learn how to parse headers, stream responses, or set status codes in a Route Handler, you are learning patterns that work in any edge or server environment, not just Next.js.
That said, Route Handlers are not a replacement for a dedicated backend when your API surface grows complex. They shine for focused, co-located endpoints. For internal mutations triggered by your own UI, Server Actions are the idiomatic choice. Route Handlers are the right tool when an external consumer — a mobile app, a webhook sender, a third-party frontend — needs a stable HTTP contract to call.
Core Concepts
Basic Route Handler
// app/api/hello/route.ts
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello, world!" });
}
HTTP Methods
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") ?? "1");
const limit = parseInt(searchParams.get("limit") ?? "10");
const posts = await db.post.findMany({
skip: (page - 1) * limit,
take: limit,
});
return NextResponse.json({ posts, page, limit });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await db.post.create({ data: body });
return NextResponse.json(post, { status: 201 });
}
Dynamic Route Parameters
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id } });
if (!post) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(post);
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const post = await db.post.update({ where: { id }, data: body });
return NextResponse.json(post);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.post.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
Caching Behavior
// Static (cached) — GET with no dynamic input
export async function GET() {
const data = await fetchStaticData();
return NextResponse.json(data);
}
// Dynamic — using request object, cookies, or headers opts out of caching
export async function GET(request: NextRequest) {
const token = request.headers.get("authorization");
// ...
}
// Force dynamic
export const dynamic = "force-dynamic";
// ISR-style revalidation
export const revalidate = 3600;
Implementation Patterns
Request Validation with Zod
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = CreatePostSchema.parse(body);
const post = await db.post.create({ data });
return NextResponse.json(post, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ errors: error.flatten().fieldErrors },
{ status: 400 }
);
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
CORS Headers
// app/api/public/route.ts
import { NextRequest, NextResponse } from "next/server";
const allowedOrigins = ["https://example.com", "https://app.example.com"];
function getCorsHeaders(origin: string | null) {
const headers: Record<string, string> = {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
if (origin && allowedOrigins.includes(origin)) {
headers["Access-Control-Allow-Origin"] = origin;
}
return headers;
}
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get("origin");
return new NextResponse(null, {
status: 204,
headers: getCorsHeaders(origin),
});
}
export async function GET(request: NextRequest) {
const origin = request.headers.get("origin");
const data = await fetchData();
return NextResponse.json(data, { headers: getCorsHeaders(origin) });
}
Webhook Handler
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const body = await request.text();
const sig = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillOrder(session);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await updateSubscription(subscription);
break;
}
}
return NextResponse.json({ received: true });
}
Streaming Responses
// app/api/chat/route.ts
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const { message } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const response = await callLLM(message);
for await (const chunk of response) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
File Upload
// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { writeFile } from "fs/promises";
import { join } from "path";
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const path = join(process.cwd(), "public", "uploads", filename);
await writeFile(path, buffer);
return NextResponse.json({ url: `/uploads/${filename}` }, { status: 201 });
}
// Increase body size limit
export const config = {
api: { bodyParser: false },
};
Middleware-Style Auth in Route Handlers
// lib/api-auth.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export async function withAuth(
handler: (request: Request, userId: string) => Promise<NextResponse>
) {
return async (request: Request) => {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, session.user.id);
};
}
Best Practices
- Use Route Handlers for external API consumers, webhooks, and streaming. For internal mutations from your own UI, prefer Server Actions.
- Validate all incoming data with Zod or a similar schema library before processing.
- Return appropriate HTTP status codes: 201 for creation, 204 for deletion, 400 for bad input, 401/403 for auth errors, 404 for missing resources.
- Handle CORS explicitly when your API is consumed by third-party frontends.
- Use
request.text()(not.json()) for webhook signature verification to preserve the raw body. - Set
export const runtime = "edge"for latency-sensitive endpoints that do not need Node.js APIs.
Common Pitfalls
route.tsandpage.tsxconflict: A directory cannot have bothroute.tsandpage.tsx. The route handler takes priority and the page will not render.- Reading the body twice:
request.json()consumes the body stream. If you need it for both validation and signature checking, read it once withrequest.text()and thenJSON.parse(). - GET caching: A
GEThandler without dynamic input is cached by default in production. If it should be fresh every request, setexport const dynamic = "force-dynamic". - Missing CORS preflight: Browsers send an
OPTIONSrequest before cross-originPOST/PUT/DELETE. If you do not handle it, the request fails silently. - Large file uploads: The default body parser has size limits. For large files, use streaming uploads to an object storage service (S3, R2) via presigned URLs rather than buffering in the Route Handler.
Anti-Patterns
-
Building a full REST API when Server Actions suffice. If the only consumer of your endpoint is your own Next.js frontend, you are adding an unnecessary network hop and serialization layer. Use Server Actions for internal mutations and reserve Route Handlers for external consumers and webhooks.
-
Skipping input validation because "it's an internal API." Every Route Handler is a publicly accessible HTTP endpoint. Omitting Zod or similar schema validation exposes you to malformed payloads, injection attacks, and hard-to-debug production errors regardless of who you think is calling it.
-
Catching errors with a bare
try/catchthat swallows context. Returning a generic 500 with no logging makes debugging impossible. Always log the original error with enough context (request path, user ID, timestamp) and return a structured error response to the caller. -
Using Route Handlers as a proxy for every database query. Fetching data in a Route Handler and then calling that endpoint from a Server Component defeats the purpose of server-first rendering. Fetch directly from the database in your Server Component and skip the unnecessary HTTP round-trip.
-
Ignoring caching semantics for GET handlers. A
GETRoute Handler with no dynamic input is cached by default in production. If you rely on fresh data but forget to setdynamic = "force-dynamic"or usecache: "no-store", users will see stale responses with no obvious explanation.
Install this skill directly: skilldb add nextjs-skills
Related Skills
App Router
App Router fundamentals including file-based routing, layouts, loading states, and parallel routes in Next.js
Authentication
Authentication patterns using NextAuth.js (Auth.js) and Clerk for protecting routes and managing sessions in Next.js
Data Fetching
Data fetching and caching strategies including Server Components, fetch options, ISR, and the Next.js cache layers
Deployment
Deployment strategies for Next.js including Vercel, self-hosting with Node.js, Docker containers, and static export
Image Optimization
Image optimization with next/image, asset handling, fonts, and performance tuning for Next.js applications
Middleware
Middleware patterns for request interception, redirects, rewrites, authentication guards, and geo-routing in Next.js