Introduction to API Response Design
Well-designed API responses are crucial for effective communication between your server and clients. A good response format provides clear information about the result of an operation, communicates errors effectively, and maintains consistency across your API.
"An API is only as good as its documentation and its response design. The response is your API's voice - make it speak clearly."
Why Response Formatting Matters
- Developer Experience: Makes your API intuitive and easy to work with
- Error Handling: Helps clients handle errors gracefully
- Debugging: Makes troubleshooting issues faster and more efficient
- Client Integration: Enables simpler client-side integration
- Documentation: Serves as a form of self-documentation
HTTP Status Codes
HTTP status codes are standardized responses that indicate the result of an HTTP request. Understanding and using them correctly is fundamental to good API design.
Status Code Categories
| Range | Category | Meaning |
|---|---|---|
| 100-199 | Informational | Request received, continuing process |
| 200-299 | Success | Request successfully received, understood, and accepted |
| 300-399 | Redirection | Further action needed to complete the request |
| 400-499 | Client Error | Request contains bad syntax or cannot be fulfilled |
| 500-599 | Server Error | Server failed to fulfill a valid request |
Common Status Codes for RESTful APIs
Success Codes
- 200 OK: Request succeeded (general success)
- 201 Created: Resource created successfully
- 204 No Content: Request succeeded, but no content to return (often used for DELETE)
Client Error Codes
- 400 Bad Request: Server cannot process the request due to client error
- 401 Unauthorized: Authentication required or failed
- 403 Forbidden: Server understood but refuses to authorize
- 404 Not Found: Requested resource not found
- 409 Conflict: Request conflicts with current state of the server
- 422 Unprocessable Entity: Request was well-formed but contains semantic errors
- 429 Too Many Requests: Client has sent too many requests in a given time
Server Error Codes
- 500 Internal Server Error: Generic server error message
- 502 Bad Gateway: Server acting as gateway received an invalid response
- 503 Service Unavailable: Server temporarily unavailable (overloaded/maintenance)
Real-World Example: REST API Status Codes by Operation
| Operation | Success | Common Error Codes |
|---|---|---|
| GET /users | 200 OK | 401, 403 |
| GET /users/:id | 200 OK | 404, 401, 403 |
| POST /users | 201 Created | 400, 409, 422 |
| PUT /users/:id | 200 OK | 400, 404, 422 |
| PATCH /users/:id | 200 OK | 400, 404, 422 |
| DELETE /users/:id | 204 No Content | 404, 403 |
Setting Status Codes in Express
Express provides a simple way to set HTTP status codes in your responses:
// Basic approach
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json(user);
});
Chaining Methods
The res.status() method returns the response object, allowing you to chain methods:
// Chained methods
app.post('/users', (req, res) => {
try {
const newUser = createUser(req.body);
return res.status(201).json(newUser);
} catch (error) {
if (error.type === 'validation') {
return res.status(400).json({ error: error.message });
}
return res.status(500).json({ error: 'Server error' });
}
});
Status Code Convenience Methods
Express provides convenience methods for common status codes:
// Using convenience methods
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.sendStatus(404); // Sends "Not Found" as the response body
}
res.json(user); // 200 OK by default
});
// Other convenience methods
res.sendStatus(204); // "No Content"
res.sendStatus(400); // "Bad Request"
res.sendStatus(401); // "Unauthorized"
res.sendStatus(403); // "Forbidden"
res.sendStatus(500); // "Internal Server Error"
Note: Default Status Codes
Express sets status codes for you in many cases:
res.json(),res.send(): 200 OK by defaultres.end(): 200 OK by default- When nothing is sent: 404 Not Found
It's best practice to explicitly set status codes for clarity.
Response Formatting Patterns
Consistency in your API responses creates a better developer experience. Here are common patterns for formatting responses in Express.
Basic Success Response
A simple, consistent structure for success responses:
// Basic success response
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
return res.status(200).json({
success: true,
data: user
});
});
Metadata Pattern
Including metadata with your responses can provide additional context:
// Response with metadata
app.get('/users', (req, res) => {
const { page = 1, limit = 10 } = req.query;
const { users, total } = getUsers(page, limit);
return res.status(200).json({
success: true,
data: users,
meta: {
total,
page: parseInt(page),
limit: parseInt(limit),
pages: Math.ceil(total / limit)
}
});
});
Error Response Pattern
A consistent error format helps clients handle errors properly:
// Structured error response
app.post('/users', (req, res) => {
try {
const newUser = createUser(req.body);
return res.status(201).json({
success: true,
data: newUser
});
} catch (error) {
return res.status(error.statusCode || 500).json({
success: false,
error: {
message: error.message || 'An unexpected error occurred',
code: error.code || 'INTERNAL_ERROR',
details: error.details || null
}
});
}
});
Collection Response Pattern
For endpoints returning collections:
// Collection response with metadata
app.get('/posts', (req, res) => {
const { page = 1, limit = 10 } = req.query;
const { posts, total } = getPosts(page, limit);
return res.status(200).json({
success: true,
data: {
items: posts,
total,
page: parseInt(page),
limit: parseInt(limit),
pages: Math.ceil(total / limit)
}
});
});
JSend and Other Standards
Several standards exist for API response formatting. One popular option is JSend, a simple specification that suggests a format for API responses.
JSend Format
// Success response
{
"status": "success",
"data": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
}
}
// Error response
{
"status": "error",
"message": "Unable to communicate with database"
}
// Fail response (validation error)
{
"status": "fail",
"data": {
"email": "Email is already in use",
"password": "Password must be at least 8 characters"
}
}
Implementing JSend in Express
// JSend middleware
const jsend = {
success: (res, data) => {
return res.json({
status: 'success',
data: data || null
});
},
fail: (res, data, statusCode = 400) => {
return res.status(statusCode).json({
status: 'fail',
data
});
},
error: (res, message, statusCode = 500, code = null, data = null) => {
const response = {
status: 'error',
message
};
if (code) response.code = code;
if (data) response.data = data;
return res.status(statusCode).json(response);
}
};
// Usage
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return jsend.fail(res, { id: 'User not found' }, 404);
}
return jsend.success(res, user);
});
app.post('/users', (req, res) => {
try {
const validationErrors = validateUser(req.body);
if (validationErrors) {
return jsend.fail(res, validationErrors, 422);
}
const newUser = createUser(req.body);
return jsend.success(res, newUser);
} catch (error) {
return jsend.error(res, 'Server error occurred', 500, 'SERVER_ERROR');
}
});
Other Response Standards
JSON:API
A more comprehensive specification for building APIs in JSON:
// JSON:API response
{
"data": {
"type": "users",
"id": "1",
"attributes": {
"name": "John Doe",
"email": "john@example.com"
},
"relationships": {
"posts": {
"data": [
{ "type": "posts", "id": "1" },
{ "type": "posts", "id": "2" }
]
}
}
},
"included": [
{
"type": "posts",
"id": "1",
"attributes": {
"title": "Introduction to Express"
}
},
{
"type": "posts",
"id": "2",
"attributes": {
"title": "API Response Formatting"
}
}
]
}
OData
A protocol for building and consuming RESTful APIs:
// OData response
{
"@odata.context": "https://api.example.com/users",
"value": [
{
"id": 1,
"name": "John Doe",
"email": "john@example.com"
},
{
"id": 2,
"name": "Jane Smith",
"email": "jane@example.com"
}
],
"@odata.count": 2
}
Choosing a Standard
When selecting a response format standard, consider:
- Simplicity: How easy is it to implement and understand?
- Client Compatibility: Does it work well with your client applications?
- Team Familiarity: Is your team familiar with the standard?
- Requirements: Does it meet your specific API requirements?
For most applications, a simple consistent approach like JSend is sufficient. More complex APIs with relationships might benefit from JSON:API.
Error Handling Strategies
Effective error handling is crucial for building robust APIs. Express provides several ways to handle errors and format error responses.
Centralized Error Handling
Express allows for centralized error handling with middleware:
// Custom error class
class AppError extends Error {
constructor(message, statusCode, code = 'ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
Error.captureStackTrace(this, this.constructor);
}
}
// Error handling middleware
const errorHandler = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
// Development error response with stack trace
if (process.env.NODE_ENV === 'development') {
return res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
}
// Production error response (no stack trace)
if (err.isOperational) {
// Operational errors (expected errors)
return res.status(err.statusCode).json({
status: err.status,
message: err.message
});
}
// Programming or unknown errors
console.error('ERROR 💥', err);
return res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
};
// Using the error handling in routes
app.get('/users/:id', (req, res, next) => {
const user = findUser(req.params.id);
if (!user) {
return next(new AppError('User not found', 404, 'USER_NOT_FOUND'));
}
res.status(200).json({
status: 'success',
data: { user }
});
});
// Register error handler (must be last middleware)
app.use(errorHandler);
Async Error Handling
Handling errors in async routes requires special attention:
// Utility to catch async errors
const catchAsync = fn => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
// Using with async route handlers
app.get('/users/:id', catchAsync(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) {
return next(new AppError('User not found', 404, 'USER_NOT_FOUND'));
}
res.status(200).json({
status: 'success',
data: { user }
});
}));
Error Handling for Different Scenarios
// Handle mongoose validation errors
if (error.name === 'ValidationError') {
const errors = Object.values(error.errors).map(err => err.message);
return next(new AppError(`Invalid input data. ${errors.join('. ')}`, 422, 'VALIDATION_ERROR'));
}
// Handle duplicate key errors
if (error.code === 11000) {
const field = Object.keys(error.keyValue)[0];
return next(new AppError(`Duplicate field value: ${field}. Please use another value.`, 409, 'DUPLICATE_ERROR'));
}
// Handle JWT errors
if (error.name === 'JsonWebTokenError') {
return next(new AppError('Invalid token. Please log in again.', 401, 'INVALID_TOKEN'));
}
// Handle expired JWT
if (error.name === 'TokenExpiredError') {
return next(new AppError('Your token has expired. Please log in again.', 401, 'EXPIRED_TOKEN'));
}
Real-World Example: Error Handling in a Payment API
Consider a payment processing API that needs detailed error handling:
app.post('/api/payments', catchAsync(async (req, res, next) => {
try {
// Validate payment details
const { amount, cardNumber, cvv, expiryDate } = req.body;
// Insufficient funds error
if (await checkBalance(req.user.id) < amount) {
return next(new AppError('Insufficient funds', 400, 'INSUFFICIENT_FUNDS'));
}
// Card validation error
if (!validateCard(cardNumber, cvv, expiryDate)) {
return next(new AppError('Invalid card details', 400, 'INVALID_CARD'));
}
// Process payment
const payment = await processPayment({
userId: req.user.id,
amount,
cardDetails: { cardNumber, cvv, expiryDate }
});
return res.status(201).json({
status: 'success',
data: { payment }
});
} catch (error) {
// Gateway error
if (error.code === 'GATEWAY_ERROR') {
return next(new AppError('Payment processor unavailable', 503, 'PAYMENT_UNAVAILABLE'));
}
// Card declined
if (error.code === 'CARD_DECLINED') {
return next(new AppError('Card declined by issuer', 400, 'CARD_DECLINED'));
}
// Unknown errors
return next(error);
}
}));
This example shows how different types of errors are handled with appropriate status codes and error messages, making it clear to the client what went wrong with their payment attempt.
Response Headers
HTTP headers provide additional information about the response. Properly setting response headers is important for security, caching, and additional metadata.
Setting Response Headers
// Setting a single header
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Set content type header
res.setHeader('Content-Type', 'application/json');
// Send response
res.status(200).json(user);
});
// Setting multiple headers
app.get('/api/data', (req, res) => {
const data = getData();
// Set multiple headers
res.set({
'Content-Type': 'application/json',
'Cache-Control': 'max-age=3600',
'X-API-Version': '1.0.0'
});
res.status(200).json(data);
});
Common Response Headers
| Header | Purpose | Example |
|---|---|---|
| Content-Type | Specifies the media type of the resource | Content-Type: application/json |
| Cache-Control | Directives for caching mechanisms | Cache-Control: max-age=3600 |
| Access-Control-Allow-Origin | CORS header that specifies allowed origins | Access-Control-Allow-Origin: * |
| Content-Length | Size of the response body in bytes | Content-Length: 2048 |
| Expires | Date/time after which the response is considered stale | Expires: Wed, 21 Oct 2025 07:28:00 GMT |
| Location | Used for redirects or to indicate the URL of a newly created resource | Location: /api/users/123 |
Custom Headers
Custom headers can provide additional information about your API:
// Custom headers for API metadata
app.use((req, res, next) => {
// Add custom headers to all responses
res.set({
'X-API-Version': '1.2.0',
'X-Request-ID': generateRequestId(),
'X-Response-Time': calculateResponseTime()
});
next();
});
// Custom headers for rate limiting
app.get('/api/data', (req, res) => {
// Rate limit information in headers
res.set({
'X-RateLimit-Limit': '100',
'X-RateLimit-Remaining': '95',
'X-RateLimit-Reset': '1589458800'
});
// Send response
res.status(200).json(getData());
});
Header Naming Conventions
When creating custom headers:
- Prefix with
X-to indicate it's a custom header (though this convention is deprecated) - Use descriptive names that indicate the header's purpose
- Be consistent with casing (typically hyphenated Pascal case)
- Ensure values are properly formatted (dates in RFC format, numbers as strings)
Content Negotiation
Content negotiation allows your API to serve different representations of the same resource based on client preferences.
Handling Different Content Types
// Responding with different content types
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Check Accept header for content negotiation
const acceptHeader = req.get('Accept');
// Respond with JSON (default)
if (!acceptHeader || acceptHeader.includes('application/json')) {
return res.status(200).json(user);
}
// Respond with XML
if (acceptHeader.includes('application/xml')) {
const xml = convertToXML(user);
return res.status(200)
.set('Content-Type', 'application/xml')
.send(xml);
}
// Respond with plain text
if (acceptHeader.includes('text/plain')) {
const text = `User ID: ${user.id}\nName: ${user.name}\nEmail: ${user.email}`;
return res.status(200)
.set('Content-Type', 'text/plain')
.send(text);
}
// If no acceptable format is found
return res.status(406).json({
error: 'Not Acceptable',
message: 'Supported formats: application/json, application/xml, text/plain'
});
});
Using Express middleware for Content Negotiation
// Express content negotiation with middleware
const express = require('express');
const app = express();
// Content negotiation middleware
app.use((req, res, next) => {
// Original methods
const originalJson = res.json;
const originalSend = res.send;
// Override json method
res.json = function(obj) {
const acceptHeader = req.get('Accept');
// If client accepts JSON or no preference
if (!acceptHeader || acceptHeader.includes('application/json')) {
return originalJson.call(this, obj);
}
// If client prefers XML
if (acceptHeader.includes('application/xml')) {
const xml = convertToXML(obj);
return this.set('Content-Type', 'application/xml').send(xml);
}
// Default to JSON
return originalJson.call(this, obj);
};
next();
});
// Now all res.json() calls will automatically handle content negotiation
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// This will use the negotiated format based on Accept header
return res.status(200).json(user);
});
Format-Specific Endpoints
An alternative approach is to provide format-specific endpoints:
// Format-specific endpoints
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
return res.status(200).json(user);
});
app.get('/users/:id.xml', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).send(convertToXML({ error: 'User not found' }));
}
const xml = convertToXML(user);
return res.status(200)
.set('Content-Type', 'application/xml')
.send(xml);
});
app.get('/users/:id.csv', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).send('Error,User not found');
}
const csv = convertToCSV(user);
return res.status(200)
.set('Content-Type', 'text/csv')
.set('Content-Disposition', 'attachment; filename="user.csv"')
.send(csv);
});
Real-World Example: Report API with Multiple Formats
An expense reporting API that supports different export formats:
app.get('/api/reports/expenses', (req, res) => {
const { startDate, endDate, userId } = req.query;
const expenses = getExpenseReport(userId, startDate, endDate);
// Get requested format
const format = req.query.format || 'json';
switch (format.toLowerCase()) {
case 'json':
return res.status(200).json({
status: 'success',
data: {
expenses,
total: calculateTotal(expenses),
period: { startDate, endDate }
}
});
case 'csv':
const csv = convertToCSV(expenses);
return res.status(200)
.set('Content-Type', 'text/csv')
.set('Content-Disposition', 'attachment; filename="expenses.csv"')
.send(csv);
case 'pdf':
const pdfBuffer = generatePDF(expenses, startDate, endDate);
return res.status(200)
.set('Content-Type', 'application/pdf')
.set('Content-Disposition', 'attachment; filename="expenses.pdf"')
.send(pdfBuffer);
case 'excel':
const excelBuffer = generateExcel(expenses, startDate, endDate);
return res.status(200)
.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
.set('Content-Disposition', 'attachment; filename="expenses.xlsx"')
.send(excelBuffer);
default:
return res.status(400).json({
status: 'fail',
message: 'Unsupported format. Supported formats: json, csv, pdf, excel'
});
}
});
Pagination and Response Metadata
For API endpoints that return collections of resources, pagination is essential. Proper response formatting for paginated results includes metadata about the pagination.
Basic Pagination
// Basic pagination
app.get('/api/products', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
// Get paginated results
const { products, total } = getProducts(skip, limit);
// Calculate pagination metadata
const totalPages = Math.ceil(total / limit);
const hasNextPage = page < totalPages;
const hasPrevPage = page > 1;
// Return formatted response
res.status(200).json({
status: 'success',
data: products,
meta: {
pagination: {
total,
page,
limit,
totalPages,
hasNextPage,
hasPrevPage
}
}
});
});
HATEOAS Pattern
HATEOAS (Hypermedia as the Engine of Application State) adds links to responses for navigation:
// HATEOAS pagination
app.get('/api/products', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
// Get paginated results
const { products, total } = getProducts(skip, limit);
// Calculate pagination metadata
const totalPages = Math.ceil(total / limit);
const baseUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`;
// Generate links
const links = {};
// Self link (current page)
links.self = `${baseUrl}?page=${page}&limit=${limit}`;
// First page link
links.first = `${baseUrl}?page=1&limit=${limit}`;
// Last page link
links.last = `${baseUrl}?page=${totalPages}&limit=${limit}`;
// Next page link (if exists)
if (page < totalPages) {
links.next = `${baseUrl}?page=${page + 1}&limit=${limit}`;
}
// Previous page link (if exists)
if (page > 1) {
links.prev = `${baseUrl}?page=${page - 1}&limit=${limit}`;
}
// Return formatted response
res.status(200).json({
status: 'success',
data: products,
meta: {
pagination: {
total,
page,
limit,
totalPages
}
},
links
});
});
Cursor-Based Pagination
For large datasets, cursor-based pagination is often more efficient:
// Cursor-based pagination
app.get('/api/posts', (req, res) => {
const limit = parseInt(req.query.limit) || 10;
const cursor = req.query.cursor;
// Get results after cursor
const { posts, nextCursor } = getPostsAfterCursor(cursor, limit);
// Return formatted response
res.status(200).json({
status: 'success',
data: posts,
meta: {
pagination: {
limit,
nextCursor: nextCursor || null,
hasPrevious: !!cursor
}
},
links: {
self: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
next: nextCursor
? `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}?cursor=${nextCursor}&limit=${limit}`
: null,
prev: cursor
? `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}?cursor=${getPreviousCursor(cursor)}&limit=${limit}`
: null
}
});
});
Pagination Headers
You can also include pagination information in HTTP headers:
// Adding pagination headers
res.set({
'X-Total-Count': total.toString(),
'X-Page': page.toString(),
'X-Per-Page': limit.toString(),
'X-Total-Pages': totalPages.toString(),
'X-Has-Next-Page': hasNextPage.toString(),
'X-Has-Prev-Page': hasPrevPage.toString()
});
This approach keeps the response body clean while still providing pagination metadata.
Versioning Your API Responses
API versioning helps manage changes to your API without breaking existing clients. Your response format may need to change between versions.
URL-Based Versioning
// V1 response format
app.get('/api/v1/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
// V1 response format
return res.status(200).json({
id: user.id,
name: user.name,
email: user.email
});
});
// V2 response format
app.get('/api/v2/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({
status: 'fail',
message: 'User not found'
});
}
// V2 response format (with JSend)
return res.status(200).json({
status: 'success',
data: {
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
createdAt: user.createdAt
}
}
});
});
Header-Based Versioning
// Version based on Accept header
app.get('/api/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
// Check API version from Accept header
const acceptHeader = req.get('Accept');
// V2 format (application/vnd.myapi.v2+json)
if (acceptHeader && acceptHeader.includes('application/vnd.myapi.v2+json')) {
return res.status(200).json({
status: 'success',
data: {
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
createdAt: user.createdAt
}
}
});
}
// Default to V1 format
return res.status(200).json({
id: user.id,
name: user.name,
email: user.email
});
});
Query Parameter Versioning
// Version based on query parameter
app.get('/api/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
// Check API version from query parameter
const version = req.query.version || '1';
// V2 format
if (version === '2') {
return res.status(200).json({
status: 'success',
data: {
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
createdAt: user.createdAt
}
}
});
}
// Default to V1 format
return res.status(200).json({
id: user.id,
name: user.name,
email: user.email
});
});
Response Consistency
Maintaining consistent response formats across your API is crucial for a good developer experience. Here are strategies to ensure consistency.
Response Formatting Middleware
// Response formatter middleware
const responseFormatter = (req, res, next) => {
// Store original methods
const originalJson = res.json;
const originalSend = res.send;
// Override json method
res.json = function(data) {
// Format based on status code
let formattedResponse;
// Success response (2xx)
if (res.statusCode >= 200 && res.statusCode < 300) {
formattedResponse = {
success: true,
data: data || null
};
}
// Failure response (4xx, 5xx)
else {
formattedResponse = {
success: false,
error: {
code: data.code || 'ERROR',
message: data.message || data.error || 'Unknown error',
details: data.details || null
}
};
}
// Call original method with formatted response
return originalJson.call(this, formattedResponse);
};
next();
};
// Apply middleware to all routes
app.use(responseFormatter);
// Now all responses will be formatted consistently
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({
message: 'User not found',
code: 'USER_NOT_FOUND'
});
}
return res.status(200).json(user);
});
Response Object Helpers
// Create a response utility module
// utils/response.js
const createResponse = {
success: (data = null, meta = null) => {
const response = {
success: true,
data
};
if (meta) {
response.meta = meta;
}
return response;
},
error: (message, code = 'ERROR', details = null, statusCode = 500) => {
return {
success: false,
error: {
code,
message,
details
},
statusCode
};
},
notFound: (resource = 'Resource') => {
return {
success: false,
error: {
code: 'NOT_FOUND',
message: `${resource} not found`
},
statusCode: 404
};
},
badRequest: (message, details = null) => {
return {
success: false,
error: {
code: 'BAD_REQUEST',
message,
details
},
statusCode: 400
};
},
unauthorized: (message = 'Unauthorized') => {
return {
success: false,
error: {
code: 'UNAUTHORIZED',
message
},
statusCode: 401
};
}
};
// Usage in routes
const { createResponse } = require('../utils/response');
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
const response = createResponse.notFound('User');
return res.status(response.statusCode).json(response);
}
return res.status(200).json(createResponse.success(user));
});
API Response Controller
// Create a response controller
// controllers/apiResponse.js
class ApiResponse {
constructor(res) {
this.res = res;
}
success(data = null, meta = null, statusCode = 200) {
const response = {
success: true,
data
};
if (meta) {
response.meta = meta;
}
return this.res.status(statusCode).json(response);
}
error(message, code = 'ERROR', details = null, statusCode = 500) {
return this.res.status(statusCode).json({
success: false,
error: {
code,
message,
details
}
});
}
notFound(resource = 'Resource') {
return this.error(`${resource} not found`, 'NOT_FOUND', null, 404);
}
badRequest(message, details = null) {
return this.error(message, 'BAD_REQUEST', details, 400);
}
unauthorized(message = 'Unauthorized') {
return this.error(message, 'UNAUTHORIZED', null, 401);
}
forbidden(message = 'Forbidden') {
return this.error(message, 'FORBIDDEN', null, 403);
}
created(data = null, meta = null) {
return this.success(data, meta, 201);
}
noContent() {
return this.res.status(204).send();
}
}
// Middleware to add ApiResponse to req object
app.use((req, res, next) => {
req.apiResponse = new ApiResponse(res);
next();
});
// Usage in routes
app.get('/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return req.apiResponse.notFound('User');
}
return req.apiResponse.success(user);
});
Real-World Example: E-commerce API
Consistent response formatting for an e-commerce API:
// Product not found
// GET /api/products/999
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "Product not found"
}
}
// Invalid order request
// POST /api/orders
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid order data",
"details": {
"quantity": "Quantity must be greater than 0",
"paymentMethod": "Payment method is required"
}
}
}
// Successful product creation
// POST /api/products
{
"success": true,
"data": {
"id": 123,
"name": "Wireless Headphones",
"price": 99.99,
"category": "Electronics"
}
}
// Successful product list with pagination
// GET /api/products?page=2&limit=10
{
"success": true,
"data": [
{ "id": 11, "name": "Product 11", ... },
{ "id": 12, "name": "Product 12", ... },
...
],
"meta": {
"pagination": {
"total": 57,
"page": 2,
"limit": 10,
"totalPages": 6
}
}
}
Notice how the structure remains consistent across different endpoints and response types.
Practical Exercise
Build a Complete API Response System
Create a response formatting system for an Express.js API with the following requirements:
Requirements
- Consistent response format for all API endpoints
- Proper HTTP status codes for different scenarios
- Standardized error handling with error codes
- Support for pagination metadata
- Content negotiation for at least JSON and XML formats
Tasks
- Create a response formatting middleware
- Implement helper functions for different response types
- Create a central error handling system
- Implement pagination for collection endpoints
- Add support for content negotiation
Testing
Test your system with various scenarios:
- Successful resource retrieval (single and collection)
- Resource creation and modification
- Resource not found
- Validation errors
- Authentication and authorization errors
- Server errors
Summary
Key Takeaways
- HTTP status codes communicate the result of operations
- Consistent response formatting improves client experience
- Standard formats like JSend provide structure for responses
- Error handling should be centralized and informative
- Response headers provide additional context
- Content negotiation allows serving multiple formats
- Pagination requires metadata for client navigation
- API versioning may affect response formats
Additional Resources
- MDN HTTP Status Codes
- JSend Specification
- JSON:API Specification
- HATEOAS Principle
- Express.js Response API
Next Steps
In our next lecture, we'll explore Express Error Handling, where you'll learn how to build a robust error handling system that catches and processes errors consistently across your Express application.
Further Practice
Exercises
- Create a middleware that automatically formats all API responses based on the JSend specification.
- Implement a logging system that records API responses with their status codes and response times.
- Create an error handling system that converts different types of errors (database, validation, etc.) into appropriate API responses.
- Implement a rate-limiting system that includes rate limit information in response headers.
- Create a content negotiation middleware that supports JSON, XML, and CSV formats.
Project Idea
Build a "Response Format Generator" library that:
- Provides a configurable way to format API responses
- Supports multiple standards (JSend, JSON:API, etc.)
- Includes error handling and pagination utilities
- Has content negotiation capabilities
- Generates appropriate HTTP headers
- Includes documentation and examples