Build REST APIs with route handlers, middleware, CORS, and auth patterns.
// app/api/users/route.ts
export async function GET(request: Request) {
return Response.json({ users: [] });
}export async function GET(request: Request) {
return Response.json({ method: "GET" });
}
export async function POST(request: Request) {
const data = await request.json();
return Response.json({ created: data });
}// app/api/users/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params; // params is a Promise in Next.js 15
return Response.json({ userId: id });
}export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const data = await request.json();
return Response.json({ updated: id });
}const { id } = await params;
const user = await getUser(id);
if (!user) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(user);[...path] segments for deeply nested API paths.// app/api/docs/[...path]/route.ts
export async function GET(
_req: Request,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params;
return Response.json({ segments: path });
}[[...path]] when the base segment should also match.app/api/search/[[...filters]]/route.ts
# matches /api/search AND /api/search/active/recentexport async function POST(request: Request) {
const data = await request.json();
console.log(data); // { name: "Alice" }
return Response.json({ success: true });
}export async function POST(request: Request) {
const formData = await request.formData();
const file = formData.get("file");
return Response.json({ uploaded: true });
}export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
return Response.json({ query });
}const { name, email } = await request.json();
if (!name || !email) {
return Response.json({ error: "name and email are required" }, { status: 400 });
}export async function POST(request: Request) {
const raw = await request.text(); // e.g. Stripe webhook payload
const sig = request.headers.get("stripe-signature");
return Response.json({ received: true });
}export async function GET(request: Request) {
const response = Response.json({ data: "hello" });
response.headers.set("Access-Control-Allow-Origin", "*");
response.headers.set("Access-Control-Allow-Methods", "GET, POST");
return response;
}// middleware.ts
export function middleware(request: Request) {
const response = Response.next();
response.headers.set("Access-Control-Allow-Origin", "*");
return response;
}export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
}
});
}const origin = request.headers.get("origin");
const allowed = ["https://myapp.com", "https://staging.myapp.com"];
const allowOrigin = allowed.includes(origin ?? "") ? origin : "";
response.headers.set("Access-Control-Allow-Origin", allowOrigin ?? "");return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
"X-Request-Id": crypto.randomUUID()
}
});import { prisma } from "@/lib/prisma";
export async function GET() {
const users = await prisma.user.findMany();
return Response.json(users);
}export async function POST(request: Request) {
try {
const data = await request.json();
if (!data.name) {
return Response.json(
{ error: "Name required" },
{ status: 400 }
);
}
return Response.json({ success: true });
} catch (error) {
return Response.json(
{ error: "Invalid request" },
{ status: 500 }
);
}
}export async function GET(request: Request) {
const token = request.headers.get("authorization");
if (!token) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return Response.json({ data: "protected" });
}const apiKey = request.headers.get("x-api-key");
if (apiKey !== process.env.API_SECRET) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}} catch (err) {
console.error("[api/users] error:", err);
return Response.json({ error: "Internal server error" }, { status: 500 });
}Export named functions matching each HTTP method from your route.ts file, such as export async function GET(request: Request) and export async function POST(request: Request). Requests with unhandled methods automatically return a 405 response.
In the App Router, params are passed as the second argument to the handler. In Next.js 15, params is a Promise — type it as { params: Promise<{ id: string }> } and await it before accessing properties: const { id } = await params. The route file goes at app/api/users/[id]/route.ts.
In App Router route handlers, the body is a ReadableStream — you must call await request.json() or await request.text() to consume it. Also ensure the client sends a Content-Type: application/json header when posting JSON.
Export a dedicated OPTIONS handler that returns the required CORS headers with a 204 status: return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type' } }). You must also include these headers on your actual GET/POST responses.
Pages Router uses pages/api/ with Node.js-style req/res objects, while App Router uses app/api/ with the standard Web Request/Response API. App Router route handlers support edge runtime and streaming natively, making them the better choice for new Next.js 13+ projects.