Skip to main content
Technology & EngineeringNextjs346 lines

API Routes

Route Handlers for building REST APIs, handling webhooks, streaming responses, and CORS in Next.js App Router

Quick Summary16 lines
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 lines
Paste into your CLAUDE.md or agent config

API 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.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.
  • 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/catch that 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 GET Route Handler with no dynamic input is cached by default in production. If you rely on fresh data but forget to set dynamic = "force-dynamic" or use cache: "no-store", users will see stale responses with no obvious explanation.

Install this skill directly: skilldb add nextjs-skills

Get CLI access →