Next.js API Routes Patterns
Build REST APIs with route handlers, middleware, CORS, and auth patterns.
TL;DR
- 01Create API routes in app/api folder for backend endpoints.
- 02Use request and response objects to handle HTTP methods.
- 03Set up CORS headers to allow safe cross-origin API requests.
Tips
- 01Keep API logic separate by using helper functions and middleware to avoid duplicating common concerns like auth and CORS across routes.
Warnings
- 01Never expose secrets or sensitive data in API responses — always validate input and use proper authentication before returning user data.
Basic Route Handlers
- Create a GET endpoint in app/api folder.
// app/api/users/route.ts export async function GET(request: Request) { return Response.json({ users: [] }); } - Handle different HTTP methods in the same file.
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 }); } - Route handlers receive a Request and return a Response.
- Use Response.json() for JSON responses.
- Each function name corresponds to an HTTP method.
Params and Catch-All Routes
- Access dynamic route params by awaiting the params object in Next.js 15.
// 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 }); } - Use the same await pattern for PUT, DELETE, and PATCH handlers.
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 }); } - Return 404 when the resource doesn't exist.
const { id } = await params; const user = await getUser(id); if (!user) { return Response.json({ error: "Not found" }, { status: 404 }); } return Response.json(user); - Use catch-all
[...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 }); } - Use optional catch-all
[[...path]]when the base segment should also match.app/api/search/[[...filters]]/route.ts # matches /api/search AND /api/search/active/recent
Request Body Parsing
- Parse JSON from the request body.
export async function POST(request: Request) { const data = await request.json(); console.log(data); // { name: "Alice" } return Response.json({ success: true }); } - Handle form data for multipart uploads.
export async function POST(request: Request) { const formData = await request.formData(); const file = formData.get("file"); return Response.json({ uploaded: true }); } - Parse query parameters from the URL.
export async function GET(request: Request) { const { searchParams } = new URL(request.url); const query = searchParams.get("q"); return Response.json({ query }); } - Validate required fields before processing and return 400 for bad input.
const { name, email } = await request.json(); if (!name || !email) { return Response.json({ error: "name and email are required" }, { status: 400 }); } - Read a plain text body with request.text() for webhooks or raw payloads.
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 }); }
CORS and Headers
- Set CORS headers for cross-origin requests.
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; } - Use middleware for consistent header handling.
// middleware.ts export function middleware(request: Request) { const response = Response.next(); response.headers.set("Access-Control-Allow-Origin", "*"); return response; } - Return OPTIONS response for preflight requests with a 204 status.
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" } }); } - Restrict CORS to specific origins instead of wildcard for production.
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 ?? ""); - Add Cache-Control and custom response headers to control caching and metadata.
return new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "X-Request-Id": crypto.randomUUID() } });
Auth and Error Patterns
- Database queries in API routes.
import { prisma } from "@/lib/prisma"; export async function GET() { const users = await prisma.user.findMany(); return Response.json(users); } - Error handling with status codes.
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 } ); } } - Authentication in API routes.
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" }); } - Extract API keys from headers and compare against an environment variable.
const apiKey = request.headers.get("x-api-key"); if (apiKey !== process.env.API_SECRET) { return Response.json({ error: "Forbidden" }, { status: 403 }); } - Log errors server-side before returning a generic 500 message to the client.
} catch (err) { console.error("[api/users] error:", err); return Response.json({ error: "Internal server error" }, { status: 500 }); }
FAQ
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.