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"
}
]
}
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
};
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;
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 };
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"));
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" }
]
}
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"
}
Every error follows the same shape. Consumers can write one error handler and be done.
Best Practices Summary
- Use RFC 9457 format — it is the industry standard and most developers expect it
-
Set the content type to
application/problem+jsonfor error responses - Never leak stack traces or internal details in production
- Include actionable details — tell consumers what went wrong and how to fix it
- Use error classes to keep your codebase clean and consistent
- Validate input early with schema validation before business logic runs
- Log unexpected errors server-side while returning safe messages to clients
-
Document your error types — the
typeURI 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)