REST API Design: Best Practices and Conventions

REST (Representational State Transfer) isn't just a buzzword—it's an architectural style that, when implemented correctly, creates APIs that are intuitive, scalable, and maintainable. Roy Fielding...

Key Insights

  • Resource URLs should use plural nouns and represent entities, not actions—use HTTP methods to define operations instead of embedding verbs in paths
  • Status codes matter: 201 for creation, 204 for deletion, 400 for client errors, and 500 for server failures communicate API behavior without documentation
  • Consistency in naming conventions, error formats, and response structures reduces cognitive load and makes your API predictable for consumers

Introduction to RESTful Principles

REST (Representational State Transfer) isn’t just a buzzword—it’s an architectural style that, when implemented correctly, creates APIs that are intuitive, scalable, and maintainable. Roy Fielding introduced REST in his doctoral dissertation, defining constraints that guide how web services should communicate.

A truly RESTful API adheres to six constraints: client-server separation, statelessness, cacheability, layered system architecture, uniform interface, and optionally, code-on-demand. Most developers focus on the uniform interface constraint, which dictates how resources are identified, manipulated through representations, and accessed through self-descriptive messages.

Why does this matter? Poor API design creates technical debt. Inconsistent endpoints force developers to constantly reference documentation. Improper HTTP method usage breaks caching strategies. Ambiguous error responses lead to defensive coding and wasted debugging time. A well-designed REST API becomes self-documenting and reduces integration friction.

Resource Naming and URL Structure

The foundation of REST API design is treating everything as a resource. Resources are nouns—users, products, orders—not verbs. Your URLs should identify resources, while HTTP methods define actions.

Always use plural nouns for collections, even when retrieving a single item. This creates consistency:

// Good
GET /users
GET /users/123
GET /users/123/orders
GET /users/123/orders/456

// Bad
GET /user
GET /getUser/123
GET /users/123/getAllOrders
POST /users/create

Hierarchical relationships should be reflected in URL structure. If orders belong to users, nest them appropriately. However, avoid deep nesting beyond two levels—it becomes unwieldy and often indicates you should rethink your resource modeling.

Query parameters handle filtering, sorting, and pagination:

// Express.js route examples
const express = require('express');
const router = express.Router();

// Collection with filtering and pagination
router.get('/users', (req, res) => {
  const { role, status, page = 1, limit = 20, sort = '-createdAt' } = req.query;
  
  // Build query based on parameters
  const filters = {};
  if (role) filters.role = role;
  if (status) filters.status = status;
  
  // Your database query logic here
  const users = getUsersFromDB(filters, { page, limit, sort });
  
  res.json({
    data: users,
    pagination: {
      page: parseInt(page),
      limit: parseInt(limit),
      total: getTotalCount(filters)
    }
  });
});

// Single resource
router.get('/users/:id', (req, res) => {
  const user = getUserById(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json({ data: user });
});

// Nested resource
router.get('/users/:userId/orders', (req, res) => {
  const orders = getOrdersByUserId(req.params.userId);
  res.json({ data: orders });
});

HTTP Methods and Status Codes

HTTP methods are your verbs. Each has specific semantics and idempotency characteristics:

  • GET: Retrieve resources. Idempotent and safe (no side effects).
  • POST: Create new resources. Not idempotent.
  • PUT: Replace entire resources. Idempotent.
  • PATCH: Partially update resources. Typically idempotent.
  • DELETE: Remove resources. Idempotent.

Idempotency matters for reliability. If a client retries a PUT request due to network issues, it shouldn’t create duplicate side effects. POST is the exception—multiple POSTs create multiple resources.

Status codes communicate outcomes without forcing clients to parse response bodies:

// User controller with proper status codes
class UserController {
  // 200 OK - Successful GET
  async getUser(req, res) {
    const user = await User.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ 
        error: 'Not Found',
        message: 'User does not exist' 
      });
    }
    return res.status(200).json({ data: user });
  }

  // 201 Created - Successful POST
  async createUser(req, res) {
    const user = await User.create(req.body);
    return res.status(201)
      .location(`/users/${user.id}`)
      .json({ data: user });
  }

  // 200 OK or 204 No Content - Successful PUT
  async updateUser(req, res) {
    const user = await User.findByIdAndUpdate(
      req.params.id, 
      req.body, 
      { new: true }
    );
    if (!user) {
      return res.status(404).json({ 
        error: 'Not Found',
        message: 'User does not exist' 
      });
    }
    return res.status(200).json({ data: user });
  }

  // 204 No Content - Successful DELETE
  async deleteUser(req, res) {
    const user = await User.findByIdAndDelete(req.params.id);
    if (!user) {
      return res.status(404).json({ 
        error: 'Not Found',
        message: 'User does not exist' 
      });
    }
    return res.status(204).send();
  }

  // 400 Bad Request - Validation error
  async createUserWithValidation(req, res) {
    const { error } = validateUser(req.body);
    if (error) {
      return res.status(400).json({
        error: 'Bad Request',
        message: 'Validation failed',
        details: error.details
      });
    }
    const user = await User.create(req.body);
    return res.status(201).json({ data: user });
  }
}

Use 400 for client errors (bad input), 401 for authentication failures, 403 for authorization failures, 404 for missing resources, and 500 for server errors. Avoid overusing 200 for everything.

Request and Response Design

Consistency in data format eliminates surprises. Choose a naming convention and stick with it. JavaScript naturally uses camelCase, so that’s the pragmatic choice for Node.js APIs:

// Consistent response wrapper
const responseWrapper = (data, meta = {}) => {
  return {
    data,
    meta: {
      timestamp: new Date().toISOString(),
      ...meta
    }
  };
};

// Standardized error format
class ApiError extends Error {
  constructor(statusCode, message, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.details = details;
  }
}

// Error handling middleware
const errorHandler = (err, req, res, next) => {
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      error: err.message,
      details: err.details,
      path: req.path,
      timestamp: new Date().toISOString()
    });
  }

  // Unhandled errors
  console.error(err);
  return res.status(500).json({
    error: 'Internal Server Error',
    message: 'An unexpected error occurred',
    timestamp: new Date().toISOString()
  });
};

// Pagination helper
const paginateResponse = (data, page, limit, total) => {
  return {
    data,
    pagination: {
      page: parseInt(page),
      limit: parseInt(limit),
      total,
      totalPages: Math.ceil(total / limit),
      hasNext: page * limit < total,
      hasPrev: page > 1
    }
  };
};

// Usage example
router.get('/products', async (req, res, next) => {
  try {
    const { page = 1, limit = 20, category, minPrice, maxPrice } = req.query;
    
    const filters = {};
    if (category) filters.category = category;
    if (minPrice) filters.price = { $gte: parseFloat(minPrice) };
    if (maxPrice) filters.price = { ...filters.price, $lte: parseFloat(maxPrice) };
    
    const products = await Product.find(filters)
      .skip((page - 1) * limit)
      .limit(parseInt(limit));
    
    const total = await Product.countDocuments(filters);
    
    res.json(paginateResponse(products, page, limit, total));
  } catch (error) {
    next(error);
  }
});

Versioning Strategies

APIs evolve. Breaking changes are inevitable. Versioning prevents chaos when you need to modify contracts.

URL versioning is the most common and explicit approach:

const express = require('express');
const app = express();

// Version 1 routes
const v1Router = express.Router();
v1Router.get('/users', (req, res) => {
  // Old implementation
  res.json({ users: [] });
});

// Version 2 routes with breaking changes
const v2Router = express.Router();
v2Router.get('/users', (req, res) => {
  // New implementation with different response structure
  res.json({ 
    data: [],
    meta: { version: 2 }
  });
});

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

// Alternative: Header-based versioning
app.use('/api/users', (req, res) => {
  const version = req.headers['api-version'] || '1';
  
  if (version === '2') {
    return res.json({ data: [], meta: { version: 2 } });
  }
  
  return res.json({ users: [] });
});

URL versioning is transparent and cache-friendly. Header-based versioning keeps URLs clean but requires clients to set headers correctly. Choose URL versioning unless you have compelling reasons otherwise.

Authentication and Security Best Practices

Security isn’t optional. Implement authentication, rate limiting, and input validation from day one.

const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const cors = require('cors');

// JWT middleware
const authenticateJWT = (req, res, next) => {
  const authHeader = req.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  
  const token = authHeader.substring(7);
  
  try {
    const user = jwt.verify(token, process.env.JWT_SECRET);
    req.user = user;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
};

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  message: 'Too many requests from this IP, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});

// Security setup
app.use(helmet());
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
  credentials: true
}));
app.use('/api/', limiter);

// Protected route
router.post('/users/:id/orders', authenticateJWT, async (req, res, next) => {
  try {
    // Verify user can create orders for this user ID
    if (req.user.id !== req.params.id && req.user.role !== 'admin') {
      return res.status(403).json({ error: 'Forbidden' });
    }
    
    const order = await Order.create({
      userId: req.params.id,
      ...req.body
    });
    
    res.status(201).json({ data: order });
  } catch (error) {
    next(error);
  }
});

Always validate and sanitize input. Use libraries like Joi or express-validator. Never trust client data.

Conclusion and Additional Resources

REST API design is about creating predictable, maintainable interfaces. Follow these principles:

  • Use nouns for resources, plural for collections
  • Let HTTP methods define actions
  • Return appropriate status codes
  • Maintain consistent response formats
  • Version your API from the start
  • Implement security and rate limiting

Document your API with OpenAPI/Swagger. Tools like swagger-jsdoc generate documentation from code comments, keeping docs synchronized with implementation.

Good API design pays dividends. It reduces support burden, accelerates client integration, and makes your API a pleasure to use. Invest time upfront in thoughtful design—your future self and your API consumers will thank you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.