Next.js API Routes Patterns

Apply common API patterns in Next.js route handlers: validation, auth, CORS, errors, and webhooks.

TL;DR

  1. Validate every request body before you use it, for example with zod.
  2. Check the session inside each route.ts handler before returning private data.
  3. Return consistent JSON errors with the right status code.

Validation

    z.object()

    Describe the shape of a valid request body once with a schema.

    import { z } from 'zod';
    
    const PostSchema = z.object({
      title: z.string().min(1).max(100),
      body: z.string(),
    });
    safeParse()

    Check the body against the schema and return a 400 when it fails.

    const json = await request.json();
    const parsed = PostSchema.safeParse(json);
    
    if (!parsed.success) {
      return Response.json(
        { error: parsed.error.issues },
        { status: 400 }
      );
    }
    Bad JSON

    Catch a malformed body so the handler returns a 400 instead of crashing.

    let json: unknown;
    try {
      json = await request.json();
    } catch {
      return Response.json(
        { error: 'Invalid JSON' },
        { status: 400 }
      );
    }

Authentication

    Session Check

    Read the session first and return a 401 when nobody is signed in.

    export async function GET() {
      const user = await getSession();
      if (!user) {
        return Response.json(
          { error: 'Unauthorized' },
          { status: 401 }
        );
      }
      return Response.json(await getOrders(user.id));
    }
    Bearer Token

    Compare the Authorization header with a secret for machine-to-machine calls.

    const auth = request.headers.get('authorization');
    const token = auth?.replace('Bearer ', '');
    
    if (token !== process.env.API_TOKEN) {
      return new Response('Unauthorized', { status: 401 });
    }
    Role Check

    Return a 403 when the user is signed in but lacks permission.

    if (user.role !== 'admin') {
      return Response.json(
        { error: 'Forbidden' },
        { status: 403 }
      );
    }

CORS and Errors

    CORS Headers

    List the origin, methods, and headers that other sites may use.

    const cors = {
      'Access-Control-Allow-Origin': 'https://example.com',
      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    };
    OPTIONS

    Answer the browser's preflight request with the CORS headers and no body.

    export async function OPTIONS() {
      return new Response(null, {
        status: 204,
        headers: cors,
      });
    }
    Error Helper

    Build errors in one place so every endpoint returns the same shape.

    function fail(message: string, status = 400) {
      return Response.json({ error: message }, { status });
    }
    
    return fail('Post not found', 404);
    try / catch

    Log the real error on the server and return a generic 500 message.

    try {
      return Response.json(await getReport());
    } catch (err) {
      console.error(err);
      return fail('Something went wrong', 500);
    }

Webhooks and Streaming

    request.text()

    Read the raw body of a webhook so you can verify its signature.

    export async function POST(request: Request) {
      const raw = await request.text();
      const sig = request.headers.get('x-signature');
      if (!verify(raw, sig)) {
        return new Response('Bad sig', { status: 400 });
      }
      const event = JSON.parse(raw);
      return Response.json({ received: true });
    }
    createHmac()

    Compare a hash of the body with the signature header from the provider.

    import { createHmac, timingSafeEqual } from 'crypto';
    
    function verify(raw: string, sig: string | null) {
      const hash = createHmac('sha256', SECRET)
        .update(raw)
        .digest('hex');
      if (!sig || sig.length !== hash.length) return false;
      return timingSafeEqual(
        Buffer.from(hash),
        Buffer.from(sig)
      );
    }
    ReadableStream

    Send a response in chunks, which suits long-running or AI-generated output.

    export async function GET() {
      const enc = new TextEncoder();
      const stream = new ReadableStream({
        start(controller) {
          controller.enqueue(enc.encode('Hello, '));
          controller.enqueue(enc.encode('world'));
          controller.close();
        },
      });
      return new Response(stream);
    }

Tips

  1. Wrap shared logic such as auth checks in a small helper function, so every route.ts handler stays short and consistent.
  2. Read webhook bodies with request.text(), because signature checks need the exact raw bytes that were sent.

Warnings

  1. Setting Access-Control-Allow-Origin to * lets any site call your API, so list the exact origins you trust.
  2. Relying only on proxy.ts for auth is unsafe, so verify the user again inside each handler that returns data.

In Practice

FAQ