Error Handling

beginner

Consistent error handling with custom error classes and structured responses.

apierror-handlingmiddleware
Tested on201619TS5.9
$ bunx sinew add api/error-handling

1The Problem

Inconsistent error handling leads to poor user experiences and makes debugging difficult:

  • Some endpoints return detailed errors while others return generic 500s
  • Stack traces leak to clients in production
  • No standardized error format for frontend consumption
  • Different error types need different HTTP status codes

2The Solution

Create custom error classes that extend a base AppError class. Each error type carries its own status code and error code, making error handling declarative.

Use a withErrorHandler wrapper to catch all errors and convert them to consistent JSON responses.

3Files

lib/errors.ts

lib/errors.tsTypeScript
export class AppError extends Error {
  constructor(
    message: string,
    public statusCode: number = 500,
    public code: string = "INTERNAL_ERROR"
  ) {
    super(message);
    this.name = "AppError";
  }

  toJSON() {
    return {
      error: this.code,
      message: this.message,
      statusCode: this.statusCode,
    };
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string = "Resource") {
    super(`${resource} not found`, 404, "NOT_FOUND");
    this.name = "NotFoundError";
  }
}

export class UnauthorizedError extends AppError {
  constructor(message: string = "Authentication required") {
    super(message, 401, "UNAUTHORIZED");
    this.name = "UnauthorizedError";
  }
}

export class ForbiddenError extends AppError {
  constructor(message: string = "Access denied") {
    super(message, 403, "FORBIDDEN");
    this.name = "ForbiddenError";
  }
}

export class ValidationError extends AppError {
  constructor(
    message: string = "Validation failed",
    public details?: Record<string, string[]>
  ) {
    super(message, 400, "VALIDATION_ERROR");
    this.name = "ValidationError";
  }

  toJSON() {
    return {
      ...super.toJSON(),
      details: this.details,
    };
  }
}

export class ConflictError extends AppError {
  constructor(message: string = "Resource already exists") {
    super(message, 409, "CONFLICT");
    this.name = "ConflictError";
  }
}

export class RateLimitError extends AppError {
  constructor(retryAfter?: number) {
    super("Too many requests", 429, "RATE_LIMITED");
    this.name = "RateLimitError";
  }
}

lib/api/error-handler.ts

lib/api/error-handler.tsTypeScript
import { NextResponse } from "next/server";
import { AppError } from "@/lib/errors";
import { ZodError } from "zod";

interface ErrorResponse {
  error: string;
  message: string;
  details?: unknown;
}

export function handleApiError(error: unknown): NextResponse<ErrorResponse> {
  console.error("API Error:", error);

  if (error instanceof AppError) {
    return NextResponse.json(error.toJSON(), { status: error.statusCode });
  }

  if (error instanceof ZodError) {
    return NextResponse.json(
      {
        error: "VALIDATION_ERROR",
        message: "Invalid request data",
        details: error.flatten().fieldErrors,
      },
      { status: 400 }
    );
  }

  // Handle Prisma errors
  if (error && typeof error === "object" && "code" in error) {
    const dbError = error as { code: string };

    if (dbError.code === "P2002") {
      return NextResponse.json(
        { error: "CONFLICT", message: "A record with this value already exists" },
        { status: 409 }
      );
    }

    if (dbError.code === "P2025") {
      return NextResponse.json(
        { error: "NOT_FOUND", message: "Record not found" },
        { status: 404 }
      );
    }
  }

  return NextResponse.json(
    { error: "INTERNAL_ERROR", message: "An unexpected error occurred" },
    { status: 500 }
  );
}

export function withErrorHandler<T extends unknown[]>(
  handler: (...args: T) => Promise<NextResponse>
) {
  return async (...args: T): Promise<NextResponse> => {
    try {
      return await handler(...args);
    } catch (error) {
      return handleApiError(error);
    }
  };
}

app/api/example/route.ts

app/api/example/route.tsTypeScript
import { NextRequest, NextResponse } from "next/server";
import { withErrorHandler } from "@/lib/api/error-handler";
import { NotFoundError, ValidationError } from "@/lib/errors";

export const GET = withErrorHandler(async (req: NextRequest) => {
  const id = req.nextUrl.searchParams.get("id");

  if (!id) {
    throw new ValidationError("Missing required parameter: id");
  }

  const item = null; // await db.item.findUnique({ where: { id } });

  if (!item) {
    throw new NotFoundError("Item");
  }

  return NextResponse.json({ data: item });
});

app/error.tsx

app/error.tsxTypeScript
"use client";

import { useEffect } from "react";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    console.error("Page Error:", error);
  }, [error]);

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h1 className="text-4xl font-bold">Something went wrong</h1>
        <p className="mt-2 text-muted-foreground">
          {error.message || "An unexpected error occurred"}
        </p>
        <button
          onClick={reset}
          className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-lg"
        >
          Try again
        </button>
      </div>
    </div>
  );
}

4Usage

Throwing Errors in API Routes

import { NotFoundError, UnauthorizedError } from "@/lib/errors";

// In an API route
if (!session) {
  throw new UnauthorizedError();
}

const user = await db.user.findUnique({ where: { id } });
if (!user) {
  throw new NotFoundError("User");
}
TypeScript

Handling Errors on the Frontend

const response = await fetch("/api/users/123");
const data = await response.json();

if (!response.ok) {
  // data.error contains the error code
  // data.message contains human-readable message
  if (data.error === "NOT_FOUND") {
    // Handle not found
  }
}
TypeScript

5Troubleshooting

Stack traces appearing in production

Make sure you're not passing the original error message to clients. The handleApiError function returns generic messages for unknown errors.

Error boundaries not catching errors

Error boundaries only work for client components. Server component errors need to be caught differently or will show the error.tsx page.

Related patterns