Response Formatting and Status Codes in Express.js

Creating consistent, informative API responses for better client experiences

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

flowchart LR A[Client Request] --> B[Express Server] B --> C{Process Request} C -->|Success| D[Format Success Response] C -->|Error| E[Format Error Response] D --> F[Set Status Code] E --> G[Set Status Code] F --> H[Send Response] G --> H H --> I[Client]

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

Client Error Codes

Server Error Codes

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 default
  • res.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)
    }
  });
});
flowchart TB A[Response Format Patterns] --> B[Basic Pattern] A --> C[Metadata Pattern] A --> D[Error Pattern] A --> E[Collection Pattern] B --> F["{ success, data }"] C --> G["{ success, data, meta }"] D --> H["{ success, error: { message, code, details } }"] E --> I["{ success, data: { items, pagination } }"]

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
  });
});
graph TD A[API Versioning] --> B[URL Based] A --> C[Header Based] A --> D[Query Parameter] B --> E["/api/v1/resource"] B --> F["/api/v2/resource"] C --> G["Accept: application/vnd.api.v1+json"] C --> H["Accept: application/vnd.api.v2+json"] D --> I["?version=1"] D --> J["?version=2"]

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

  1. Create a response formatting middleware
  2. Implement helper functions for different response types
  3. Create a central error handling system
  4. Implement pagination for collection endpoints
  5. 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

Additional Resources

mindmap root((API Response Design)) Status Codes Success Codes Client Error Codes Server Error Codes Response Format Consistency Standards Metadata Error Handling Centralized Informative Actionable Content Negotiation JSON XML Other Formats Pagination Offset-based Cursor-based HATEOAS

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

  1. Create a middleware that automatically formats all API responses based on the JSend specification.
  2. Implement a logging system that records API responses with their status codes and response times.
  3. Create an error handling system that converts different types of errors (database, validation, etc.) into appropriate API responses.
  4. Implement a rate-limiting system that includes rate limit information in response headers.
  5. Create a content negotiation middleware that supports JSON, XML, and CSV formats.

Project Idea

Build a "Response Format Generator" library that: