Next.js App Router in Production · Rendering, Caching and Mutations

Route Handlers for APIs and Webhooks

Build JSON APIs with Route Handlers: validate request bodies, return proper status codes, and verify and deduplicate signed webhooks.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

An /api/orders API (list, create with validation, get by ID with 404) and a /api/webhooks/payments endpoint that verifies an HMAC signature over the raw body, rejects bad signatures with 401, and ignores duplicate deliveries by event ID.

The idea

  • app/**/route.ts exports functions named after HTTP methods (GET, POST, ...) using the Web Request/Response APIs.
  • With Cache Components, a GET handler that reads nothing request-specific is prerendered; reading the request (URL, headers, body) makes it run per request.
  • Validate JSON bodies (zod) and return Response.json(body, { status }) with proper codes.
  • Webhooks: verify the signature over the raw body with a constant-time comparison, then process idempotently, because providers retry and may deliver an event more than once.

The code

A validated JSON API and a signed, idempotent webhook
// file: next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true };
export default nextConfig;

// file: app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body><main>{children}</main></body>
    </html>
  );
}

// file: .env
WEBHOOK_SECRET=whsec_example

// file: app/lib/orders.ts
import "server-only";

type Order = { id: string; item: string; quantity: number; status: string };
const store = globalThis as unknown as { orders?: Map<string, Order>; events?: Set<string> };
store.orders ??= new Map([["o1", { id: "o1", item: "Mug", quantity: 2, status: "pending" }]]);
store.events ??= new Set();
export const orders = store.orders;
export const processedEvents = store.events;

// file: app/api/orders/route.ts
import { z } from "zod";
import { orders } from "../../lib/orders";

const NewOrder = z.object({ item: z.string().min(1), quantity: z.number().int().min(1).max(10) });

export async function GET(request: Request) {
  const status = new URL(request.url).searchParams.get("status");
  const list = [...orders.values()].filter((o) => !status || o.status === status);
  return Response.json(list);
}

export async function POST(request: Request) {
  const parsed = NewOrder.safeParse(await request.json().catch(() => null));
  if (!parsed.success) {
    return Response.json({ errors: z.flattenError(parsed.error).fieldErrors }, { status: 400 });
  }
  const order = { id: `o${orders.size + 1}`, ...parsed.data, status: "pending" };
  orders.set(order.id, order);
  return Response.json(order, { status: 201, headers: { Location: `/api/orders/${order.id}` } });
}

// file: app/api/orders/[id]/route.ts
import { orders } from "../../../lib/orders";

export async function GET(_request: Request, { params }: RouteContext<"/api/orders/[id]">) {
  const order = orders.get((await params).id);
  return order ? Response.json(order) : Response.json({ error: "order not found" }, { status: 404 });
}

// file: app/api/webhooks/payments/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { orders, processedEvents } from "../../../lib/orders";

function validSignature(rawBody: string, signature: string | null) {
  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!).update(rawBody).digest("hex");
  return signature !== null && signature.length === expected.length &&
    timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

export async function POST(request: Request) {
  const rawBody = await request.text();                      // verify the exact bytes that were signed
  if (!validSignature(rawBody, request.headers.get("x-signature"))) {
    return Response.json({ error: "invalid signature" }, { status: 401 });
  }
  const event = JSON.parse(rawBody) as { id: string; type: string; orderId: string };
  if (processedEvents.has(event.id)) return Response.json({ received: true, duplicate: true });
  processedEvents.add(event.id);
  const order = orders.get(event.orderId);
  if (order && event.type === "payment.succeeded") order.status = "paid";
  return Response.json({ received: true });
}

// request: POST /api/orders {"item": "Teapot", "quantity": 1}
// request: POST /api/orders {"item": "", "quantity": 50}
// request: GET /api/orders/o9
// request: POST /api/webhooks/payments header:x-signature=0000 {"id":"evt_1","type":"payment.succeeded","orderId":"o1"}
// request: POST /api/webhooks/payments header:x-signature=11fd6270771ee5e2933bc2f6740cc3fa8721d27ba1995a835c74b44bdbe89334 {"id":"evt_1","type":"payment.succeeded","orderId":"o1"}
// request: POST /api/webhooks/payments header:x-signature=11fd6270771ee5e2933bc2f6740cc3fa8721d27ba1995a835c74b44bdbe89334 {"id":"evt_1","type":"payment.succeeded","orderId":"o1"}
// request: GET /api/orders?status=paid

Output

POST /api/orders -> 201
{"id": "o2", "item": "Teapot", "quantity": 1, "status": "pending"}
POST /api/orders -> 400
{"errors": {"item": ["Too small: expected string to have >=1 characters"], "quantity": ["Too big: expected number to be <=10"]}}
GET /api/orders/o9 -> 404
{"error": "order not found"}
POST /api/webhooks/payments -> 401
{"error": "invalid signature"}
POST /api/webhooks/payments -> 200
{"received": true}
POST /api/webhooks/payments -> 200
{"received": true, "duplicate": true}
GET /api/orders?status=paid -> 200
[{"id": "o1", "item": "Mug", "quantity": 2, "status": "paid"}]

Run this on your computer — see “Get Started” for setup.

The .env file is only here to keep the example self-contained; in production, set secrets in the platform's environment, not in committed files.

Common mistakes

  • Parsing the JSON and re-serializing it before verifying the signature; whitespace or key order changes break the check. Verify the raw body.
  • Comparing signatures with === instead of a constant-time comparison.
  • Processing webhook events without deduplication; providers retry on timeouts.
  • Building a separate internal API for your own pages when Server Components can read data directly; Route Handlers are for external clients, webhooks and non-HTML responses.

Key points

  • Route Handlers use Web Request/Response; validate bodies and return correct status codes.
  • Verify webhook signatures over the raw body in constant time.
  • Make webhook processing idempotent by event ID.

Further reading: Route Handlers · Backend for Frontend guide · Stripe: verify webhook signatures

Practice questions

Why verify webhook signatures over the raw body?

Answer

The signature is computed over the exact bytes sent; parsing and re-serializing JSON can change whitespace or key order and break verification.

Why must webhook handlers be idempotent?

Answer

Providers retry deliveries on errors or timeouts and may send the same event more than once, so processing must be safe to repeat (deduplicate by event ID).

When should you use a Route Handler instead of a Server Component or Server Action?

Answer

For endpoints used by external clients, webhooks, or non-HTML responses; your own pages can read data in Server Components and mutate with Server Actions.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?