Next.js API Routes Patterns

Build REST APIs with route handlers, middleware, CORS, and auth patterns.

TL;DR

  1. 01Create API routes in app/api folder for backend endpoints.
  2. 02Use request and response objects to handle HTTP methods.
  3. 03Set up CORS headers to allow safe cross-origin API requests.

Tips

  1. 01Keep API logic separate by using helper functions and middleware to avoid duplicating common concerns like auth and CORS across routes.

Warnings

  1. 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