13.2 Route Handlers — API Endpoints
What Are Route Handlers?
Route Handlers is the way to create API endpoints in the Next.js 15 App Router. Creating a route.ts file inside the app directory turns that path into an API endpoint.
They replace the pages/api approach from the Pages Router and use the Web standard Request and Response APIs. This means they work not only in the Node.js environment but also in the Edge Runtime.
Route Handlers vs. Server Actions
| Route Handlers | Server Actions | |
|---|---|---|
| Purpose | REST/webhook APIs called by external systems | Calling server logic directly from Client Components |
| Invocation | HTTP requests (fetch, curl, Postman, etc.) | Function calls (inside components) |
| File location | app/.../route.ts | app/actions/*.ts or inside components |
| Best for | Webhooks, mobile app APIs, external service integrations | Form handling, data mutation, UI interactions |
Basic Syntax
Defining HTTP Methods
Export functions named after HTTP methods from the route.ts file.
// app/api/hello/route.ts
import { NextRequest, NextResponse } from "next/server";
// GET /api/hello
export async function GET(request: NextRequest) {
return NextResponse.json({ message: "Hello, World!" });
}
// POST /api/hello
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ received: body }, { status: 201 });
}
// PUT /api/hello
export async function PUT(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ updated: body });
}
// DELETE /api/hello
export async function DELETE(request: NextRequest) {
return NextResponse.json({ deleted: true });
}
// PATCH /api/hello
export async function PATCH(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ patched: body });
}
Supported HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS
Path Parameters (Dynamic Routes)
// app/api/users/[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 user = await db.user.findUnique({ where: { id } });
if (!user) {
return NextResponse.json({ error: "User not found." }, { status: 404 });
}
return NextResponse.json(user);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.user.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
Request Handling
Reading Query Parameters
// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get("q") ?? "";
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "10", 10);
const category = searchParams.get("category");
const sortBy = searchParams.get("sortBy") ?? "createdAt";
const order = searchParams.get("order") === "asc" ? "asc" : "desc";
const where = {
...(query && { name: { contains: query, mode: "insensitive" as const } }),
...(category && { category }),
};
const [products, total] = await Promise.all([
db.product.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { [sortBy]: order },
}),
db.product.count({ where }),
]);
return NextResponse.json({
data: products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
});
}
Handling Request Body
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters."),
email: z.string().email("Invalid email format."),
password: z.string().min(8, "Password must be at least 8 characters."),
role: z.enum(["user", "admin"]).default("user"),
});
export async function POST(request: NextRequest) {
// Parse based on Content-Type
const contentType = request.headers.get("content-type") ?? "";
let body: unknown;
if (contentType.includes("application/json")) {
body = await request.json();
} else if (contentType.includes("application/x-www-form-urlencoded")) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries());
} else {
return NextResponse.json(
{ error: "Unsupported Content-Type." },
{ status: 415 }
);
}
// Validation
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Invalid input.", details: result.error.flatten() },
{ status: 422 }
);
}
// Check for duplicate email
const existing = await db.user.findUnique({ where: { email: result.data.email } });
if (existing) {
return NextResponse.json(
{ error: "Email is already in use." },
{ status: 409 }
);
}
// Hash password and save
const hashedPassword = await hashPassword(result.data.password);
const user = await db.user.create({
data: { ...result.data, password: hashedPassword },
select: { id: true, name: true, email: true, role: true, createdAt: true },
});
return NextResponse.json(user, { status: 201 });
}
Reading and Setting Headers
// app/api/protected/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
// Read request headers
const authorization = request.headers.get("authorization");
const apiKey = request.headers.get("x-api-key");
const userAgent = request.headers.get("user-agent");
// Validate Bearer token
if (!authorization?.startsWith("Bearer ")) {
return NextResponse.json(
{ error: "Authentication token required." },
{ status: 401 }
);
}
const token = authorization.slice(7);
const payload = await verifyToken(token);
if (!payload) {
return NextResponse.json(
{ error: "Invalid token." },
{ status: 401 }
);
}
const data = await fetchProtectedData(payload.userId);
// Set response headers
const response = NextResponse.json({ data });
response.headers.set("X-Response-Time", Date.now().toString());
response.headers.set("Cache-Control", "private, no-cache");
return response;
}
Handling File Uploads
// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { writeFile } from "fs/promises";
import path from "path";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
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 });
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "File type not allowed. (JPEG, PNG, WebP, GIF only)" },
{ status: 415 }
);
}
// Validate file size
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: "File size exceeds 5MB." },
{ status: 413 }
);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Generate filename (to avoid collisions)
const ext = file.name.split(".").pop();
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const uploadPath = path.join(process.cwd(), "public/uploads", filename);
await writeFile(uploadPath, buffer);
return NextResponse.json({
url: `/uploads/${filename}`,
name: file.name,
size: file.size,
type: file.type,
});
}
Response Handling
Various Response Formats
// app/api/examples/route.ts
import { NextRequest, NextResponse } from "next/server";
// JSON response
export async function GET(request: NextRequest) {
const url = request.nextUrl.pathname;
if (url.includes("json")) {
return NextResponse.json({ hello: "world" });
}
// Text response
if (url.includes("text")) {
return new NextResponse("Hello, World!", {
headers: { "Content-Type": "text/plain" },
});
}
// HTML response
if (url.includes("html")) {
return new NextResponse("<h1>Hello</h1>", {
headers: { "Content-Type": "text/html" },
});
}
// Streaming response
if (url.includes("stream")) {
const stream = new ReadableStream({
async start(controller) {
const messages = ["Hello", " World", "!"];
for (const msg of messages) {
controller.enqueue(new TextEncoder().encode(msg));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
return new NextResponse(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
// Redirect
return NextResponse.redirect(new URL("/api/examples/json", request.url));
}
Cache Control
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
// Static data — cache for 1 hour
export async function GET(request: NextRequest) {
const posts = await db.post.findMany({
where: { published: true },
select: { id: true, title: true, slug: true, publishedAt: true },
});
return NextResponse.json(posts, {
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
// Dynamic data — disable cache
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,
headers: {
"Cache-Control": "no-store",
},
});
}
// Next.js cache configuration
export const dynamic = "force-dynamic"; // disable cache
// export const revalidate = 3600; // revalidate every hour
Using Middleware
middleware.ts — Request Pre-processing
// middleware.ts (project root)
import { NextRequest, NextResponse } from "next/server";
import { verifyToken } from "@/lib/auth";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Protect API routes
if (pathname.startsWith("/api/admin")) {
const token = request.headers.get("authorization")?.slice(7) ??
request.cookies.get("token")?.value;
if (!token) {
return NextResponse.json(
{ error: "Authentication required." },
{ status: 401 }
);
}
const payload = await verifyToken(token);
if (!payload || payload.role !== "admin") {
return NextResponse.json(
{ error: "Admin privileges required." },
{ status: 403 }
);
}
// Forward verified user info via headers
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-user-id", payload.userId);
requestHeaders.set("x-user-role", payload.role);
return NextResponse.next({ request: { headers: requestHeaders } });
}
// Rate limiting (simple example)
if (pathname.startsWith("/api/")) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
const key = `rate-limit:${ip}`;
// In real implementations, use Redis etc.
// const count = await redis.incr(key);
// if (count > 100) {
// return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
// }
}
return NextResponse.next();
}
export const config = {
matcher: ["/api/:path*", "/admin/:path*"],
};
Middleware Pattern Inside Route Handlers
// lib/api-middleware.ts
import { NextRequest, NextResponse } from "next/server";
type Handler = (req: NextRequest, context?: unknown) => Promise<NextResponse>;
type Middleware = (handler: Handler) => Handler;
// CORS middleware
export const withCors: Middleware = (handler) => async (req, ctx) => {
const origin = req.headers.get("origin");
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") ?? [];
const response = await handler(req, ctx);
if (origin && allowedOrigins.includes(origin)) {
response.headers.set("Access-Control-Allow-Origin", origin);
}
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
return response;
};
// Auth middleware
export const withAuth: Middleware = (handler) => async (req, ctx) => {
const token = req.headers.get("authorization")?.slice(7);
if (!token) {
return NextResponse.json({ error: "Authentication required." }, { status: 401 });
}
const payload = await verifyToken(token);
if (!payload) {
return NextResponse.json({ error: "Invalid token." }, { status: 401 });
}
return handler(req, ctx);
};
// Logging middleware
export const withLogging: Middleware = (handler) => async (req, ctx) => {
const start = Date.now();
const response = await handler(req, ctx);
const duration = Date.now() - start;
console.log(`[${req.method}] ${req.nextUrl.pathname} - ${response.status} (${duration}ms)`);
return response;
};
// Compose middlewares
export function compose(...middlewares: Middleware[]): Middleware {
return (handler) => middlewares.reduceRight((h, mw) => mw(h), handler);
}
// app/api/admin/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { compose, withCors, withAuth, withLogging } from "@/lib/api-middleware";
async function handler(request: NextRequest): Promise<NextResponse> {
const users = await db.user.findMany({
select: { id: true, name: true, email: true, role: true },
});
return NextResponse.json(users);
}
export const GET = compose(withLogging, withAuth, withCors)(handler);
Real-world Example — Full REST API Implementation
Implementing a complete REST API for blog posts.
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).optional().default([]),
published: z.boolean().optional().default(false),
});
// GET /api/posts — list posts
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10));
const limit = Math.min(50, parseInt(searchParams.get("limit") ?? "10", 10));
const tag = searchParams.get("tag");
const published = searchParams.get("published") !== "false";
const where = {
published,
...(tag && { tags: { has: tag } }),
};
const [posts, total] = await Promise.all([
db.post.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: "desc" },
include: { author: { select: { id: true, name: true } } },
}),
db.post.count({ where }),
]);
return NextResponse.json({
data: posts,
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
}
// POST /api/posts — create post
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Login required." }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON format." }, { status: 400 });
}
const result = CreatePostSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Input error", details: result.error.flatten() },
{ status: 422 }
);
}
const slug = result.data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const slugExists = await db.post.findUnique({ where: { slug } });
if (slugExists) {
return NextResponse.json({ error: "A post with this title already exists." }, { status: 409 });
}
const post = await db.post.create({
data: {
...result.data,
slug,
authorId: session.user.id,
publishedAt: result.data.published ? new Date() : null,
},
include: { author: { select: { id: true, name: true } } },
});
return NextResponse.json(post, { status: 201 });
}
// app/api/posts/[slug]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";
const UpdatePostSchema = z.object({
title: z.string().min(1).max(200).optional(),
content: z.string().min(10).optional(),
tags: z.array(z.string()).optional(),
published: z.boolean().optional(),
});
// GET /api/posts/:slug
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const post = await db.post.findUnique({
where: { slug },
include: {
author: { select: { id: true, name: true, image: true } },
comments: {
include: { user: { select: { id: true, name: true } } },
orderBy: { createdAt: "desc" },
take: 10,
},
},
});
if (!post) {
return NextResponse.json({ error: "Post not found." }, { status: 404 });
}
// Increment view count (async, ignore errors)
db.post.update({ where: { slug }, data: { viewCount: { increment: 1 } } }).catch(() => {});
return NextResponse.json(post);
}
// PATCH /api/posts/:slug
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Login required." }, { status: 401 });
}
const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) {
return NextResponse.json({ error: "Post not found." }, { status: 404 });
}
if (post.authorId !== session.user.id && session.user.role !== "admin") {
return NextResponse.json({ error: "You do not have permission to edit this." }, { status: 403 });
}
const body = await request.json();
const result = UpdatePostSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Input error", details: result.error.flatten() },
{ status: 422 }
);
}
const updated = await db.post.update({
where: { slug },
data: {
...result.data,
updatedAt: new Date(),
...(result.data.published && !post.published && { publishedAt: new Date() }),
},
});
return NextResponse.json(updated);
}
// DELETE /api/posts/:slug
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Login required." }, { status: 401 });
}
const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) {
return NextResponse.json({ error: "Post not found." }, { status: 404 });
}
if (post.authorId !== session.user.id && session.user.role !== "admin") {
return NextResponse.json({ error: "You do not have permission to delete this." }, { status: 403 });
}
await db.post.delete({ where: { slug } });
return new NextResponse(null, { status: 204 });
}
Webhook Handling
Processing webhooks from external services (Stripe, GitHub, Slack, etc.).
// 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!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: NextRequest) {
const body = await request.text(); // raw body required
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "No signature provided." }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Signature verification failed" }, { status: 400 });
}
// Handle event types
switch (event.type) {
case "payment_intent.succeeded": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handlePaymentSuccess(paymentIntent);
break;
}
case "payment_intent.payment_failed": {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await handlePaymentFailure(paymentIntent);
break;
}
case "customer.subscription.created":
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionChange(subscription);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCanceled(subscription);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
async function handlePaymentSuccess(intent: Stripe.PaymentIntent) {
const orderId = intent.metadata.orderId;
await db.order.update({
where: { id: orderId },
data: { status: "PAID", paidAt: new Date() },
});
}
async function handlePaymentFailure(intent: Stripe.PaymentIntent) {
const orderId = intent.metadata.orderId;
await db.order.update({
where: { id: orderId },
data: { status: "PAYMENT_FAILED" },
});
}
Expert Tips
1. Using the Edge Runtime
// app/api/geo/route.ts
export const runtime = "edge"; // use edge runtime
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
// Geolocation info is available at the Edge
const country = request.geo?.country ?? "unknown";
const city = request.geo?.city ?? "unknown";
const region = request.geo?.region ?? "unknown";
return NextResponse.json({ country, city, region });
}
2. Implementing SSE (Server-Sent Events)
// app/api/events/route.ts
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const sendEvent = (event: string, data: unknown) => {
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(message));
};
// Simulate real-time updates
let count = 0;
const interval = setInterval(() => {
sendEvent("update", { count: ++count, timestamp: new Date().toISOString() });
if (count >= 10) {
clearInterval(interval);
controller.close();
}
}, 1000);
// Clean up when client disconnects
request.signal.addEventListener("abort", () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Subscribing to SSE on the client:
// app/dashboard/RealtimeUpdates.tsx
"use client";
import { useEffect, useState } from "react";
export default function RealtimeUpdates() {
const [updates, setUpdates] = useState<Array<{ count: number; timestamp: string }>>([]);
useEffect(() => {
const eventSource = new EventSource("/api/events");
eventSource.addEventListener("update", (e) => {
const data = JSON.parse(e.data);
setUpdates((prev) => [...prev, data].slice(-20)); // keep last 20
});
eventSource.onerror = () => {
console.error("SSE connection error");
eventSource.close();
};
return () => eventSource.close();
}, []);
return (
<div>
<h2>Real-time Updates</h2>
<ul>
{updates.map((update, i) => (
<li key={i}>
#{update.count} — {new Date(update.timestamp).toLocaleTimeString("en-US")}
</li>
))}
</ul>
</div>
);
}
3. Handling CORS Preflight with an OPTIONS Handler
// app/api/data/route.ts
import { NextRequest, NextResponse } from "next/server";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "https://your-frontend.com",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
export async function OPTIONS(_request: NextRequest) {
return NextResponse.json({}, { headers: CORS_HEADERS });
}
export async function GET(request: NextRequest) {
const data = await fetchData();
return NextResponse.json(data, { headers: CORS_HEADERS });
}
4. Response Caching Strategies
// app/api/stats/route.ts
import { NextResponse } from "next/server";
// ISR-style, regenerated every 10 minutes
export const revalidate = 600;
export async function GET() {
const stats = await db.$transaction([
db.user.count(),
db.post.count({ where: { published: true } }),
db.comment.count(),
]);
return NextResponse.json({
users: stats[0],
posts: stats[1],
comments: stats[2],
generatedAt: new Date().toISOString(),
});
}
5. Creating a Type-safe API Client
// lib/api-client.ts
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string; details?: unknown };
async function apiRequest<T>(
url: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
try {
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
return { success: false, error: error.error ?? "Request failed", details: error.details };
}
const data = await response.json();
return { success: true, data };
} catch (error) {
return { success: false, error: "Network error" };
}
}
// Usage examples
export const postsApi = {
list: (params?: { page?: number; limit?: number }) =>
apiRequest<{ data: Post[]; meta: PaginationMeta }>(
`/api/posts?${new URLSearchParams(params as Record<string, string>)}`
),
get: (slug: string) => apiRequest<Post>(`/api/posts/${slug}`),
create: (data: CreatePostInput) =>
apiRequest<Post>("/api/posts", {
method: "POST",
body: JSON.stringify(data),
}),
update: (slug: string, data: UpdatePostInput) =>
apiRequest<Post>(`/api/posts/${slug}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
delete: (slug: string) =>
apiRequest<void>(`/api/posts/${slug}`, { method: "DELETE" }),
};