REST API Design: Building Intuitive and Scalable APIs

REST API Design: Building Intuitive and Scalable APIs

Well-designed REST APIs are intuitive, consistent, and scalable. Let’s explore the principles and patterns that make APIs developer-friendly and maintainable.

Resource-Based URL Structure

Design URLs around resources, not actions:

// Bad - Action-based URLs
POST   /getAllUsers
POST   /createNewUser
POST   /updateUser
POST   /deleteUser

// Good - Resource-based URLs
GET    /users           // Get all users
POST   /users           // Create a user
GET    /users/:id       // Get a specific user
PUT    /users/:id       // Update a user
PATCH  /users/:id       // Partially update a user
DELETE /users/:id       // Delete a user

HTTP Methods (Verbs)

Use appropriate HTTP methods for operations:

const express = require('express');
const router = express.Router();

// GET - Retrieve resources (safe, idempotent)
router.get('/articles', async (req, res) => {
  try {
    const articles = await Article.find();
    res.json({
      success: true,
      data: articles
    });
  } catch (error) {
    res.status(500).json({ success: false, message: error.message });
  }
});

// POST - Create a new resource (not idempotent)
router.post('/articles', async (req, res) => {
  try {
    const article = new Article(req.body);
    await article.save();
    res.status(201).json({
      success: true,
      data: article
    });
  } catch (error) {
    res.status(400).json({ success: false, message: error.message });
  }
});

// PUT - Replace entire resource (idempotent)
router.put('/articles/:id', async (req, res) => {
  try {
    const article = await Article.findByIdAndUpdate(
      req.params.id,
      req.body,
      { new: true, overwrite: true }
    );
    res.json({
      success: true,
      data: article
    });
  } catch (error) {
    res.status(400).json({ success: false, message: error.message });
  }
});

// PATCH - Partially update resource (idempotent)
router.patch('/articles/:id', async (req, res) => {
  try {
    const article = await Article.findByIdAndUpdate(
      req.params.id,
      { $set: req.body },
      { new: true }
    );
    res.json({
      success: true,
      data: article
    });
  } catch (error) {
    res.status(400).json({ success: false, message: error.message });
  }
});

// DELETE - Remove resource (idempotent)
router.delete('/articles/:id', async (req, res) => {
  try {
    await Article.findByIdAndDelete(req.params.id);
    res.status(204).send();
  } catch (error) {
    res.status(400).json({ success: false, message: error.message });
  }
});

HTTP Status Codes

Use appropriate status codes:

// Success codes
const SUCCESS_CODES = {
  OK: 200,                    // GET success
  CREATED: 201,               // POST success (resource created)
  NO_CONTENT: 204,            // DELETE success (no content to return)
};

// Client error codes
const CLIENT_ERROR_CODES = {
  BAD_REQUEST: 400,           // Invalid request data
  UNAUTHORIZED: 401,          // Authentication required
  FORBIDDEN: 403,             // Authenticated but not authorized
  NOT_FOUND: 404,             // Resource not found
  CONFLICT: 409,              // Conflict (e.g., duplicate email)
  UNPROCESSABLE_ENTITY: 422,  // Validation errors
  TOO_MANY_REQUESTS: 429,     // Rate limit exceeded
};

// Server error codes
const SERVER_ERROR_CODES = {
  INTERNAL_SERVER_ERROR: 500, // Generic server error
  SERVICE_UNAVAILABLE: 503,   // Temporary unavailability
};

// Example implementation
router.post('/users', async (req, res) => {
  try {
    const { email, password, name } = req.body;
    
    // Validation
    if (!email || !password || !name) {
      return res.status(422).json({
        success: false,
        message: 'Validation failed',
        errors: {
          email: !email ? 'Email is required' : undefined,
          password: !password ? 'Password is required' : undefined,
          name: !name ? 'Name is required' : undefined,
        }
      });
    }
    
    // Check for existing user
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(409).json({
        success: false,
        message: 'User with this email already exists'
      });
    }
    
    // Create user
    const user = await User.create({ email, password, name });
    res.status(201).json({
      success: true,
      data: user
    });
    
  } catch (error) {
    res.status(500).json({
      success: false,
      message: 'Internal server error'
    });
  }
});

Pagination and Filtering

Implement efficient data retrieval:

router.get('/products', async (req, res) => {
  try {
    // Pagination parameters
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 20;
    const skip = (page - 1) * limit;
    
    // Filtering
    const filter = {};
    if (req.query.category) {
      filter.category = req.query.category;
    }
    if (req.query.minPrice) {
      filter.price = { $gte: parseFloat(req.query.minPrice) };
    }
    if (req.query.maxPrice) {
      filter.price = { ...filter.price, $lte: parseFloat(req.query.maxPrice) };
    }
    
    // Sorting
    const sortBy = req.query.sortBy || 'createdAt';
    const sortOrder = req.query.order === 'asc' ? 1 : -1;
    const sort = { [sortBy]: sortOrder };
    
    // Search
    if (req.query.search) {
      filter.$text = { $search: req.query.search };
    }
    
    // Execute query
    const [products, total] = await Promise.all([
      Product.find(filter)
        .sort(sort)
        .skip(skip)
        .limit(limit),
      Product.countDocuments(filter)
    ]);
    
    // Response with metadata
    res.json({
      success: true,
      data: products,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit)
      }
    });
    
  } catch (error) {
    res.status(500).json({ success: false, message: error.message });
  }
});

// Example request:
// GET /products?page=2&limit=10&category=electronics&minPrice=100&sortBy=price&order=asc&search=laptop

API Versioning

Future-proof your API:

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

// URL versioning (most common)
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');

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

// Header versioning (alternative)
app.use('/api/users', (req, res, next) => {
  const version = req.headers['api-version'] || '1';
  
  if (version === '1') {
    // V1 logic
    res.json({ version: 1, users: [] });
  } else if (version === '2') {
    // V2 logic with additional fields
    res.json({ version: 2, users: [], metadata: {} });
  } else {
    res.status(400).json({ error: 'Unsupported API version' });
  }
});

Error Handling

Consistent error response format:

// Error handler middleware
class ApiError extends Error {
  constructor(statusCode, message, errors = null) {
    super(message);
    this.statusCode = statusCode;
    this.errors = errors;
  }
}

const errorHandler = (err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const response = {
    success: false,
    message: err.message || 'Internal server error',
  };
  
  if (err.errors) {
    response.errors = err.errors;
  }
  
  if (process.env.NODE_ENV === 'development') {
    response.stack = err.stack;
  }
  
  res.status(statusCode).json(response);
};

app.use(errorHandler);

// Usage in routes
router.post('/users', async (req, res, next) => {
  try {
    const { email } = req.body;
    
    if (!email) {
      throw new ApiError(422, 'Validation failed', {
        email: 'Email is required'
      });
    }
    
    const user = await User.create(req.body);
    res.status(201).json({ success: true, data: user });
    
  } catch (error) {
    next(error);
  }
});

Authentication and Authorization

Secure your API endpoints:

const jwt = require('jsonwebtoken');

// Authentication middleware
const authenticate = async (req, res, next) => {
  try {
    const token = req.headers.authorization?.replace('Bearer ', '');
    
    if (!token) {
      return res.status(401).json({
        success: false,
        message: 'Authentication required'
      });
    }
    
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const user = await User.findById(decoded.userId);
    
    if (!user) {
      return res.status(401).json({
        success: false,
        message: 'Invalid authentication token'
      });
    }
    
    req.user = user;
    next();
    
  } catch (error) {
    res.status(401).json({
      success: false,
      message: 'Invalid authentication token'
    });
  }
};

// Authorization middleware
const authorize = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        message: 'Insufficient permissions'
      });
    }
    next();
  };
};

// Usage
router.get('/admin/users', 
  authenticate, 
  authorize('admin', 'moderator'), 
  async (req, res) => {
    const users = await User.find();
    res.json({ success: true, data: users });
  }
);

Rate Limiting

Protect your API from abuse:

const rateLimit = require('express-rate-limit');

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

app.use('/api/', limiter);

// Stricter limit for authentication endpoints
const authLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 5, // 5 requests per hour
  skipSuccessfulRequests: true,
  message: {
    success: false,
    message: 'Too many login attempts, please try again later.'
  }
});

app.use('/api/auth/login', authLimiter);

Best Practices Checklist

// 1. Use nouns for resources, not verbs
// ✅ GET /articles
// ❌ GET /getArticles

// 2. Use plural nouns for collections
// ✅ GET /users
// ❌ GET /user

// 3. Use nested resources for relationships
// ✅ GET /users/123/posts
// ❌ GET /posts?userId=123

// 4. Return appropriate status codes
// 200: Success, 201: Created, 204: No Content
// 400: Bad Request, 401: Unauthorized, 404: Not Found

// 5. Include metadata in responses
{
  "success": true,
  "data": [],
  "pagination": { "page": 1, "total": 100 },
  "timestamp": "2025-09-10T10:00:00Z"
}

// 6. Use query parameters for filtering and sorting
// GET /products?category=electronics&sort=price&order=asc

// 7. Version your API
// /api/v1/users
// /api/v2/users

Conclusion

Well-designed REST APIs are the backbone of modern web applications. Follow these principles to create APIs that are intuitive, maintainable, and scalable. Consistency is key—establish patterns and stick to them throughout your API.