Framework Cookbooks

Next.js API Routes: Status Codes, Error Handling, and Middleware

How to handle HTTP status codes in Next.js API routes (Pages Router) and Route Handlers (App Router) — validation, error responses, and middleware patterns.

API Routes vs Route Handlers

Next.js offers two distinct models for server-side HTTP handling depending on which router you use. Understanding the difference is essential before writing any error-handling code.

Pages Router (pages/api/) gives you a Node.js-style (req, res) handler where you imperatively write to the response object:

// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method not allowed' });
  }
  res.status(200).json({ id: req.query.id, name: 'Alice' });
}

App Router (app/) uses Web Standard Request/Response objects via NextRequest/NextResponse. Handlers are named exports matching HTTP verbs:

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
  return NextResponse.json({ id: params.id, name: 'Alice' }, { status: 200 });
}

export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
  // 204 No Content — successful delete, no response body
  return new Response(null, { status: 204 });
}

The App Router approach runs on the Edge Runtime by default, which means it executes close to the user but has restricted Node.js API access (no fs, no crypto.randomUUID from node:crypto, etc.). Add export const runtime = 'nodejs' to opt into the full Node.js runtime.

Status Code Patterns

Pages Router Status Codes

In the Pages Router, res.status() is chainable and mirrors Express.js:

// 201 Created — resource was created
res.status(201).json({ id: newUser.id, created: true });

// 204 No Content — success, no body
res.status(204).end();

// 400 Bad Request — client sent invalid data
res.status(400).json({ error: 'Invalid email format' });

// 401 Unauthorized — missing or invalid authentication
res.status(401).json({ error: 'Authentication required' });

// 404 Not Found — resource does not exist
res.status(404).json({ error: 'User not found' });

// 409 Conflict — duplicate resource
res.status(409).json({ error: 'Email already registered' });

App Router Status Codes

In the App Router, NextResponse.json() accepts a second options argument:

// 201 Created
return NextResponse.json({ id: newUser.id }, { status: 201 });

// 204 No Content — must use raw Response, NextResponse.json adds Content-Type
return new Response(null, { status: 204 });

// 400 Bad Request with custom headers
return NextResponse.json(
  { error: 'Invalid payload' },
  { status: 400, headers: { 'X-Error-Code': 'INVALID_PAYLOAD' } }
);

// Streaming response (AI/LLM use case)
const stream = new ReadableStream({
  async start(controller) {
    for await (const chunk of llmResponse) {
      controller.enqueue(new TextEncoder().encode(chunk));
    }
    controller.close();
  },
});
return new Response(stream, {
  status: 200,
  headers: { 'Content-Type': 'text/event-stream' },
});

Error Handling

Pages Router: try/catch Pattern

Wrap the entire handler body and return appropriate status codes from the catch block:

// pages/api/orders.ts
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    const order = await db.orders.create(req.body);
    return res.status(201).json(order);
  } catch (err) {
    if (err instanceof ValidationError) {
      return res.status(422).json({ error: err.message, fields: err.fields });
    }
    if (err instanceof DatabaseError && err.code === 'UNIQUE_VIOLATION') {
      return res.status(409).json({ error: 'Duplicate order' });
    }
    console.error('Unhandled error:', err);
    return res.status(500).json({ error: 'Internal server error' });
  }
}

App Router: Error Files

The App Router introduces error.tsx for React component-level errors and a global app/api/error-handler.ts pattern for API errors:

// lib/api-error.ts — reusable error factory
export class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

export function handleApiError(err: unknown): NextResponse {
  if (err instanceof ApiError) {
    return NextResponse.json({ error: err.message }, { status: err.status });
  }
  console.error(err);
  return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}

// app/api/users/route.ts
import { handleApiError, ApiError } from '@/lib/api-error';

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    if (!body.email) throw new ApiError(400, 'Email is required');
    const user = await createUser(body);
    return NextResponse.json(user, { status: 201 });
  } catch (err) {
    return handleApiError(err);
  }
}

Validation with Zod

Zod is the de-facto standard for request validation in Next.js. Failed validation should return 422 Unprocessable Entity with field-level errors:

import { z } from 'zod';

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  age: z.number().int().min(18).optional(),
});

export async function POST(req: NextRequest) {
  let body: unknown;
  try {
    body = await req.json();
  } catch {
    return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
  }

  const result = CreateUserSchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        details: result.error.flatten().fieldErrors,
      },
      { status: 422 }
    );
  }

  // result.data is now typed as { email: string; name: string; age?: number }
  const user = await createUser(result.data);
  return NextResponse.json(user, { status: 201 });
}

The safeParse approach never throws — it returns a discriminated union of { success: true, data } or { success: false, error }. Always use safeParse in API handlers rather than parse, which throws a ZodError.

Middleware Patterns

Next.js middleware runs at the edge before any route handler, making it ideal for authentication checks and rate limiting:

// middleware.ts (root of project)
import { NextRequest, NextResponse } from 'next/server';

export function middleware(req: NextRequest) {
  const token = req.headers.get('authorization')?.replace('Bearer ', '');

  // Protect all /api/* routes except /api/auth/*
  if (req.nextUrl.pathname.startsWith('/api/') &&
      !req.nextUrl.pathname.startsWith('/api/auth/')) {
    if (!token || !isValidToken(token)) {
      return NextResponse.json(
        { error: 'Authentication required' },
        { status: 401 }
      );
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

Middleware can also rewrite requests and add headers to responses before they reach route handlers — useful for injecting authenticated user context or adding CORS headers globally:

// Add CORS headers via middleware
const response = NextResponse.next();
response.headers.set('Access-Control-Allow-Origin', 'https://app.example.com');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
return response;

Composing Middleware Chains

Next.js supports only one middleware file, so compose multiple concerns manually:

type MiddlewareFn = (req: NextRequest) => NextResponse | null;

function withAuth(req: NextRequest): NextResponse | null {
  const token = req.cookies.get('session')?.value;
  if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  return null; // continue
}

function withRateLimit(req: NextRequest): NextResponse | null {
  const ip = req.ip ?? '127.0.0.1';
  if (isRateLimited(ip)) {
    return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
  }
  return null;
}

export function middleware(req: NextRequest) {
  const chain: MiddlewareFn[] = [withRateLimit, withAuth];
  for (const fn of chain) {
    const result = fn(req);
    if (result) return result; // short-circuit on error
  }
  return NextResponse.next();
}

Key Takeaways

  • Use Pages Router (req/res) for compatibility with existing Express middleware patterns
  • Use App Router route handlers for edge deployments and Web Standard APIs
  • Always validate with Zod and return 422 (not 400) for field-level validation errors
  • Put cross-cutting concerns (auth, rate limiting, CORS) in middleware.ts, not in each route
  • Return new Response(null, { status: 204 }) for successful deletes — not NextResponse.json({})

Protokol Terkait

Istilah Glosarium Terkait

Lebih lanjut di Framework Cookbooks