DEV Community

1xApi
1xApi

Posted on • Originally published at 1xapi.com

How to Design Consistent API Error Responses in Node.js

How to Design Consistent API Error Responses in Node.js

Introduction

Nothing frustrates API consumers more than inconsistent error responses. One endpoint returns { error: "Not found" }, another returns { message: "404", success: false }, and a third throws raw HTML. Sound familiar?

As of February 2026, building APIs that return predictable, well-structured error responses is a baseline expectation—not a nice-to-have. In this tutorial, we will build a complete error handling system for a Node.js API using Express, following the RFC 9457 Problem Details standard that has become the industry norm.

Why Consistent Error Handling Matters

When your API returns inconsistent errors, every consumer has to write custom parsing logic for each endpoint. This leads to:

  • Frustrated developers who waste time debugging cryptic responses
  • Fragile client code that breaks when error formats change
  • Poor developer experience that drives users to competing APIs
  • Harder debugging when production issues arise

A well-designed error system lets consumers write a single error handler that works across your entire API.

The RFC 9457 Problem Details Standard

RFC 9457 (formerly RFC 7807) defines a standard format for HTTP API error responses. As of 2026, this is the most widely adopted error format across major APIs including Stripe, GitHub, and Cloudflare. The format looks like this:

{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "The 'email' field must be a valid email address.",
  "instance": "/api/v1/users",
  "errors": [
    {
      "field": "email",
      "message": "Must be a valid email address",
      "code": "INVALID_FORMAT"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Key fields:

  • type: A URI that identifies the error type (can link to documentation)
  • title: A short human-readable summary
  • status: The HTTP status code
  • detail: A human-readable explanation specific to this occurrence
  • instance: The request path that generated the error

Step 1: Create Custom Error Classes

First, define error classes that map to common HTTP error scenarios:

// errors/AppError.js
class AppError extends Error {
  constructor(statusCode, title, detail, errors = []) {
    super(detail);
    this.statusCode = statusCode;
    this.title = title;
    this.detail = detail;
    this.errors = errors;
    this.isOperational = true;

    Error.captureStackTrace(this, this.constructor);
  }

  toJSON(requestPath) {
    return {
      type: "https://api.example.com/errors/" + this.title.toLowerCase().replace(/\s+/g, "-"),
      title: this.title,
      status: this.statusCode,
      detail: this.detail,
      instance: requestPath,
      ...(this.errors.length > 0 && { errors: this.errors })
    };
  }
}

class NotFoundError extends AppError {
  constructor(resource, id) {
    super(404, "Not Found", `The ${resource} with id '${id}' was not found.`);
  }
}

class ValidationError extends AppError {
  constructor(errors) {
    super(422, "Validation Error", "One or more fields failed validation.", errors);
  }
}

class UnauthorizedError extends AppError {
  constructor(detail = "Authentication is required to access this resource.") {
    super(401, "Unauthorized", detail);
  }
}

class ForbiddenError extends AppError {
  constructor(detail = "You do not have permission to access this resource.") {
    super(403, "Forbidden", detail);
  }
}

class RateLimitError extends AppError {
  constructor(retryAfter = 60) {
    super(429, "Too Many Requests", `Rate limit exceeded. Retry after ${retryAfter} seconds.`);
    this.retryAfter = retryAfter;
  }
}

module.exports = {
  AppError, NotFoundError, ValidationError,
  UnauthorizedError, ForbiddenError, RateLimitError
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Build the Global Error Handler Middleware

// middleware/errorHandler.js
const { AppError } = require("../errors/AppError");

const errorHandler = (err, req, res, next) => {
  // Log the error for debugging
  if (!err.isOperational) {
    console.error("[UNEXPECTED ERROR]", {
      message: err.message,
      stack: err.stack,
      path: req.originalUrl,
      method: req.method,
      timestamp: new Date().toISOString()
    });
  }

  // Handle known operational errors
  if (err instanceof AppError) {
    const response = err.toJSON(req.originalUrl);

    // Add Retry-After header for rate limit errors
    if (err.retryAfter) {
      res.set("Retry-After", String(err.retryAfter));
    }

    return res
      .status(err.statusCode)
      .type("application/problem+json")
      .json(response);
  }

  // Handle Mongoose/MongoDB validation errors
  if (err.name === "ValidationError" && err.errors) {
    const validationErrors = Object.entries(err.errors).map(
      ([field, error]) => ({
        field,
        message: error.message,
        code: "VALIDATION_FAILED"
      })
    );

    return res.status(422).type("application/problem+json").json({
      type: "https://api.example.com/errors/validation-error",
      title: "Validation Error",
      status: 422,
      detail: "One or more fields failed validation.",
      instance: req.originalUrl,
      errors: validationErrors
    });
  }

  // Handle JSON parse errors
  if (err.type === "entity.parse.failed") {
    return res.status(400).type("application/problem+json").json({
      type: "https://api.example.com/errors/malformed-json",
      title: "Malformed JSON",
      status: 400,
      detail: "The request body contains invalid JSON.",
      instance: req.originalUrl
    });
  }

  // Fallback for unexpected errors - never leak internal details
  return res.status(500).type("application/problem+json").json({
    type: "https://api.example.com/errors/internal-error",
    title: "Internal Server Error",
    status: 500,
    detail: "An unexpected error occurred. Please try again later.",
    instance: req.originalUrl
  });
};

module.exports = errorHandler;
Enter fullscreen mode Exit fullscreen mode

Step 3: Add Request Validation Middleware

Use a validation library to catch bad input before it hits your business logic. Here is an example using Zod, which as of early 2026 remains one of the most popular schema validation libraries:

// middleware/validate.js
const { z } = require("zod");
const { ValidationError } = require("../errors/AppError");

const validate = (schema) => (req, res, next) => {
  try {
    schema.parse({
      body: req.body,
      query: req.query,
      params: req.params
    });
    next();
  } catch (err) {
    if (err instanceof z.ZodError) {
      const errors = err.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
        code: issue.code.toUpperCase()
      }));
      next(new ValidationError(errors));
    } else {
      next(err);
    }
  }
};

// Example schema for creating a user
const createUserSchema = z.object({
  body: z.object({
    name: z.string().min(2, "Name must be at least 2 characters"),
    email: z.string().email("Must be a valid email address"),
    age: z.number().int().min(13, "Must be at least 13 years old").optional()
  })
});

module.exports = { validate, createUserSchema };
Enter fullscreen mode Exit fullscreen mode

Step 4: Wire Everything Together

// app.js
const express = require("express");
const { NotFoundError } = require("./errors/AppError");
const errorHandler = require("./middleware/errorHandler");
const { validate, createUserSchema } = require("./middleware/validate");

const app = express();
app.use(express.json());

// Routes
app.post("/api/v1/users", validate(createUserSchema), (req, res) => {
  res.status(201).json({
    data: { id: 1, name: req.body.name, email: req.body.email }
  });
});

app.get("/api/v1/users/:id", (req, res, next) => {
  const user = null; // Simulate user not found
  if (!user) {
    return next(new NotFoundError("user", req.params.id));
  }
  res.json({ data: user });
});

// Catch unmatched routes
app.use((req, res, next) => {
  next(new NotFoundError("route", req.originalUrl));
});

// Global error handler - must be registered last
app.use(errorHandler);

app.listen(3000, () => console.log("API running on port 3000"));
Enter fullscreen mode Exit fullscreen mode

Step 5: Test Your Error Responses

Here is what consumers will see when things go wrong:

Validation error (POST /api/v1/users with bad data):

{
  "type": "https://api.example.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "One or more fields failed validation.",
  "instance": "/api/v1/users",
  "errors": [
    { "field": "body.email", "message": "Must be a valid email address", "code": "INVALID_STRING" },
    { "field": "body.name", "message": "Name must be at least 2 characters", "code": "TOO_SMALL" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Resource not found (GET /api/v1/users/999):

{
  "type": "https://api.example.com/errors/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "The user with id '999' was not found.",
  "instance": "/api/v1/users/999"
}
Enter fullscreen mode Exit fullscreen mode

Every error follows the same shape. Consumers can write one error handler and be done.

Best Practices Summary

  1. Use RFC 9457 format — it is the industry standard and most developers expect it
  2. Set the content type to application/problem+json for error responses
  3. Never leak stack traces or internal details in production
  4. Include actionable details — tell consumers what went wrong and how to fix it
  5. Use error classes to keep your codebase clean and consistent
  6. Validate input early with schema validation before business logic runs
  7. Log unexpected errors server-side while returning safe messages to clients
  8. Document your error types — the type URI should link to real documentation

Conclusion

Consistent API error handling is one of the highest-leverage improvements you can make to your API. By adopting RFC 9457 Problem Details, creating structured error classes, and centralizing error handling in middleware, you give your consumers a predictable, debuggable experience.

The complete code from this tutorial works with Express 4.x and 5.x. Copy the error classes, plug in the middleware, and your API will handle errors like a production-grade service from day one.


Building APIs? Check out 1xAPI for ready-to-use API endpoints for email verification, sports data, and more.

Top comments (0)