Why Standard API Response Format Matters
In a professional backend, every API response must follow a consistent structure. This makes it easy for the frontend developer to handle all responses uniformly with a single Axios interceptor, rather than writing different handling logic for each endpoint.
Without a standard format, one endpoint might return { user: {...} }, another might return { data: { user: {...} } }, and error responses might be plain strings. This is a maintenance nightmare.
---
Standard Response Structure
{
"success": true,
"statusCode": 200,
"message": "User fetched successfully",
"data": { "id": "...", "name": "..." }
}
For error responses:
{
"success": false,
"statusCode": 404,
"message": "User not found",
"errors": []
}
---
HTTP Status Codes Reference
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH, DELETE |
| 201 | Created | Successful POST (resource created) |
| 204 | No Content | Successful DELETE (no body needed) |
| 400 | Bad Request | Invalid input, missing required fields |
| 401 | Unauthorized | Not authenticated (no or invalid token) |
| 403 | Forbidden | Authenticated but not authorised (wrong role) |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate resource (email already exists) |
| 422 | Unprocessable Entity | Validation failed (correct format, bad data) |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server-side error |
| 503 | Service Unavailable | Server is overloaded or down for maintenance |
---
ApiResponse Class
// src/utils/ApiResponse.js
class ApiResponse {
constructor(statusCode, data, message = 'Success') {
this.statusCode = statusCode;
this.data = data;
this.message = message;
this.success = statusCode < 400;
}
}
export { ApiResponse };
Usage in a controller:
res.status(200).json(
new ApiResponse(200, user, 'User fetched successfully')
);
---
ApiError Class
// src/utils/ApiError.js
class ApiError extends Error {
constructor(
statusCode,
message = 'Something went wrong',
errors = [],
stack = ''
) {
super(message);
this.statusCode = statusCode;
this.data = null;
this.message = message;
this.success = false;
this.errors = errors;
if (stack) {
this.stack = stack;
} else {
Error.captureStackTrace(this, this.constructor);
}
}
}
export { ApiError };
Usage:
throw new ApiError(409, 'User with this email already exists');
throw new ApiError(401, 'Invalid credentials', ['email or password is wrong']);
throw new ApiError(404, 'Video not found');
---
asyncHandler — Eliminating try-catch Boilerplate
Every async controller function needs a try-catch block. Instead of writing it in every controller, wrap controllers with asyncHandler:
// src/utils/asyncHandler.js
const asyncHandler = (requestHandler) => {
return (req, res, next) => {
Promise.resolve(requestHandler(req, res, next)).catch((err) => next(err));
};
};
export { asyncHandler };
With this, you write:
const getUser = asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new ApiError(404, 'User not found');
res.status(200).json(new ApiResponse(200, user, 'User fetched'));
});
Instead of:
const getUser = async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) return next(new ApiError(404, 'User not found'));
res.status(200).json(new ApiResponse(200, user, 'User fetched'));
} catch (err) {
next(err);
}
};
---
Express Global Error Middleware
Express error middleware has exactly 4 parameters: (err, req, res, next). It must be registered after all routes:
// src/middlewares/errorHandler.js
const errorHandler = (err, req, res, next) => {
let statusCode = err.statusCode || 500;
let message = err.message || 'Internal Server Error';
// Handle Mongoose CastError (invalid ObjectId format)
if (err.name === 'CastError') {
statusCode = 400;
message = `Invalid ${err.path}: ${err.value}`;
}
// Handle Mongoose duplicate key error
if (err.code === 11000) {
statusCode = 409;
const field = Object.keys(err.keyValue)[0];
message = `${field} already exists`;
}
// Handle Mongoose validation errors
if (err.name === 'ValidationError') {
statusCode = 422;
message = Object.values(err.errors).map((e) => e.message).join(', ');
}
// Handle JWT errors
if (err.name === 'JsonWebTokenError') {
statusCode = 401;
message = 'Invalid token';
}
if (err.name === 'TokenExpiredError') {
statusCode = 401;
message = 'Token expired';
}
res.status(statusCode).json({
success: false,
statusCode,
message,
errors: err.errors || [],
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
};
export { errorHandler };
Register in app.js after all routes:
app.use(errorHandler);
---
Operational vs Programming Errors
| Type | Description | Examples | Handling |
|---|---|---|---|
| Operational | Expected errors in a working system | Invalid input, not found, duplicate | Return 4xx with ApiError |
| Programming | Bugs, unexpected states | Null reference, type errors, uncaught exceptions | Log, return 500, fix the bug |
Never expose stack traces or internal error details to the client in production. Always useNODE_ENVcheck before includingstackin the error response.