← Back to blog
API design and development architecture

RESTful API Design: Best Practices and Patterns for Production

13 min read

Well-designed APIs are the foundation of modern software architecture. Whether you're building microservices, mobile backends, or public developer platforms, your API design decisions impact developer experience, security, performance, and long-term maintainability. This comprehensive guide covers practical patterns and best practices for building RESTful APIs that scale, based on real-world experience building APIs for fintech platforms and enterprise systems. For related system design topics, see rate limiter design and distributed cache design. For professional API development services, explore my services.

REST Fundamentals and Resource Design

REST (Representational State Transfer) is an architectural style built on HTTP. Understanding its core principles helps you design intuitive, consistent APIs.

Resource-Oriented Design

REST centers on resources—the nouns of your API. Each resource has a unique identifier (URI) and supports operations through HTTP methods:

# Resources as nouns, not verbs
GET    /users              # List users
POST   /users              # Create user
GET    /users/123          # Get specific user
PUT    /users/123          # Replace user
PATCH  /users/123          # Update user partially
DELETE /users/123          # Delete user

# Nested resources for relationships
GET    /users/123/orders          # User's orders
POST   /users/123/orders          # Create order for user
GET    /users/123/orders/456      # Specific order

# Avoid verb-based endpoints
# BAD:  POST /users/123/updateEmail
# GOOD: PATCH /users/123 with {"email": "new@example.com"}

Resources should be plural nouns representing collections. Use path parameters for identification and query parameters for filtering, sorting, and pagination.

HTTP Methods and Semantics

Each HTTP method has specific semantics that clients can rely on:

| Method | Idempotent | Safe | Use Case | |--------|------------|------|----------| | GET | Yes | Yes | Retrieve resources | | POST | No | No | Create resources | | PUT | Yes | No | Replace entire resource | | PATCH | No* | No | Partial update | | DELETE | Yes | No | Remove resource |

Idempotent methods produce the same result regardless of how many times they're called. This property enables automatic retry logic and makes APIs more resilient.

// PUT is idempotent - calling twice has same effect as once
PUT /users/123
{
  "name": "John Doe",
  "email": "john@example.com",
  "role": "admin"
}

// PATCH may not be idempotent depending on operation
PATCH /users/123
{
  "role": "admin"  // Idempotent - sets to specific value
}

PATCH /users/123
{
  "loginCount": { "$increment": 1 }  // NOT idempotent
}

URI Design Best Practices

Consistent URI patterns make APIs predictable and self-documenting:

# Use lowercase with hyphens for multi-word resources
/user-profiles        # Good
/userProfiles         # Avoid (camelCase)
/user_profiles        # Avoid (underscores)

# Version in path (explicit) or header (cleaner)
/v1/users             # Path versioning
/users                # Header: Accept-Version: v1

# Use query parameters for optional filters
/orders?status=pending&sort=-created_at&limit=20

# Sub-resources for clear relationships
/users/123/preferences
/organizations/456/members

# Actions as sub-resources when necessary
POST /orders/123/cancel        # State transition
POST /users/123/verify-email   # Trigger action

Authentication and Authorization

Security is non-negotiable for production APIs. Implement authentication and authorization properly from the start.

Token-Based Authentication

JWT (JSON Web Tokens) remain the standard for stateless authentication:

const jwt = require('jsonwebtoken');

// Token generation with appropriate claims
function generateTokens(user) {
  const accessToken = jwt.sign(
    {
      sub: user.id,
      email: user.email,
      roles: user.roles,
      type: 'access'
    },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  const refreshToken = jwt.sign(
    {
      sub: user.id,
      type: 'refresh',
      tokenFamily: crypto.randomUUID()
    },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  
  return { accessToken, refreshToken };
}

// Authentication middleware
async function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'unauthorized',
      message: 'Missing or invalid authorization header'
    });
  }
  
  const token = authHeader.slice(7);
  
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    
    if (payload.type !== 'access') {
      throw new Error('Invalid token type');
    }
    
    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({
      error: 'unauthorized',
      message: 'Invalid or expired token'
    });
  }
}

Role-Based Access Control (RBAC)

Implement authorization as a separate concern from authentication:

// Define permissions per role
const rolePermissions = {
  admin: ['users:read', 'users:write', 'users:delete', 'orders:*'],
  manager: ['users:read', 'orders:read', 'orders:write'],
  user: ['orders:read', 'orders:write:own']
};

// Authorization middleware factory
function authorize(...requiredPermissions) {
  return (req, res, next) => {
    const userRoles = req.user.roles || [];
    
    // Collect all user permissions from roles
    const userPermissions = userRoles.flatMap(
      role => rolePermissions[role] || []
    );
    
    // Check if user has required permissions
    const hasPermission = requiredPermissions.every(required => {
      return userPermissions.some(perm => {
        if (perm === required) return true;
        if (perm.endsWith(':*')) {
          return required.startsWith(perm.slice(0, -1));
        }
        return false;
      });
    });
    
    if (!hasPermission) {
      return res.status(403).json({
        error: 'forbidden',
        message: 'Insufficient permissions'
      });
    }
    
    next();
  };
}

// Usage
app.delete('/users/:id', 
  authenticate, 
  authorize('users:delete'),
  deleteUser
);

API Keys for Service Authentication

For service-to-service communication or public APIs, API keys provide simpler authentication:

// API key middleware with rate limiting integration
async function authenticateApiKey(req, res, next) {
  const apiKey = req.headers['x-api-key'];
  
  if (!apiKey) {
    return res.status(401).json({
      error: 'unauthorized',
      message: 'API key required'
    });
  }
  
  // Hash API key for lookup (never store plaintext)
  const keyHash = crypto
    .createHash('sha256')
    .update(apiKey)
    .digest('hex');
  
  const keyRecord = await db.apiKeys.findByHash(keyHash);
  
  if (!keyRecord || keyRecord.revoked) {
    return res.status(401).json({
      error: 'unauthorized',
      message: 'Invalid API key'
    });
  }
  
  // Attach key metadata for rate limiting
  req.apiKey = {
    id: keyRecord.id,
    clientId: keyRecord.clientId,
    tier: keyRecord.tier,
    rateLimit: keyRecord.rateLimit
  };
  
  next();
}

For implementing rate limiting with API keys, see rate limiter design.

Response Design and Status Codes

Consistent response formats and appropriate status codes make APIs intuitive to use.

HTTP Status Codes

Use status codes correctly—they're part of your API contract:

// 2xx Success
200 OK              // Successful GET, PUT, PATCH, or DELETE
201 Created         // Successful POST creating a resource
204 No Content      // Successful DELETE with no response body

// 3xx Redirection
301 Moved Permanently   // Resource permanently moved
304 Not Modified        // Conditional GET, use cached version

// 4xx Client Errors
400 Bad Request         // Invalid request syntax/body
401 Unauthorized        // Missing or invalid authentication
403 Forbidden           // Authenticated but not authorized
404 Not Found           // Resource doesn't exist
409 Conflict            // Request conflicts with current state
422 Unprocessable Entity // Valid syntax but semantic errors
429 Too Many Requests   // Rate limit exceeded

// 5xx Server Errors
500 Internal Server Error  // Unexpected server error
502 Bad Gateway           // Upstream service error
503 Service Unavailable   // Server temporarily unavailable

Consistent Response Format

Define a standard response envelope for all endpoints:

// Success response
{
  "data": {
    "id": "123",
    "email": "user@example.com",
    "name": "John Doe",
    "createdAt": "2026-01-15T10:30:00Z"
  },
  "meta": {
    "requestId": "req_abc123"
  }
}

// Collection response with pagination
{
  "data": [
    { "id": "1", "name": "Item 1" },
    { "id": "2", "name": "Item 2" }
  ],
  "meta": {
    "requestId": "req_def456",
    "pagination": {
      "total": 150,
      "page": 1,
      "perPage": 20,
      "totalPages": 8
    }
  },
  "links": {
    "self": "/items?page=1",
    "next": "/items?page=2",
    "last": "/items?page=8"
  }
}

// Error response
{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "message": "Invalid email format"
      },
      {
        "field": "password",
        "message": "Must be at least 8 characters"
      }
    ]
  },
  "meta": {
    "requestId": "req_xyz789"
  }
}

Error Handling Implementation

Centralize error handling for consistency:

// Custom error classes
class ApiError extends Error {
  constructor(statusCode, code, message, details = null) {
    super(message);
    this.statusCode = statusCode;
    this.code = code;
    this.details = details;
  }
  
  static badRequest(message, details) {
    return new ApiError(400, 'bad_request', message, details);
  }
  
  static unauthorized(message = 'Authentication required') {
    return new ApiError(401, 'unauthorized', message);
  }
  
  static forbidden(message = 'Access denied') {
    return new ApiError(403, 'forbidden', message);
  }
  
  static notFound(resource = 'Resource') {
    return new ApiError(404, 'not_found', `${resource} not found`);
  }
  
  static conflict(message) {
    return new ApiError(409, 'conflict', message);
  }
  
  static validationError(details) {
    return new ApiError(422, 'validation_error', 'Validation failed', details);
  }
}

// Error handling middleware
function errorHandler(err, req, res, next) {
  const requestId = req.headers['x-request-id'] || crypto.randomUUID();
  
  // Log error with context
  console.error({
    requestId,
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method
  });
  
  // Handle known errors
  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details
      },
      meta: { requestId }
    });
  }
  
  // Handle validation errors from libraries
  if (err.name === 'ValidationError') {
    return res.status(422).json({
      error: {
        code: 'validation_error',
        message: 'Request validation failed',
        details: formatValidationErrors(err)
      },
      meta: { requestId }
    });
  }
  
  // Default to 500 for unknown errors
  res.status(500).json({
    error: {
      code: 'internal_error',
      message: 'An unexpected error occurred'
    },
    meta: { requestId }
  });
}

Pagination, Filtering, and Sorting

Large datasets require pagination. Implement it consistently across all collection endpoints.

Cursor-Based Pagination

Cursor pagination handles real-time data better than offset pagination:

// Cursor-based pagination implementation
async function listOrders(req, res) {
  const { cursor, limit = 20, sort = '-createdAt' } = req.query;
  const maxLimit = 100;
  const actualLimit = Math.min(parseInt(limit), maxLimit);
  
  // Parse sort parameter
  const sortField = sort.startsWith('-') ? sort.slice(1) : sort;
  const sortOrder = sort.startsWith('-') ? 'DESC' : 'ASC';
  
  // Build query with cursor
  let query = db('orders')
    .where('userId', req.user.id)
    .orderBy(sortField, sortOrder)
    .limit(actualLimit + 1); // Fetch one extra to detect hasMore
  
  if (cursor) {
    const decoded = decodeCursor(cursor);
    const operator = sortOrder === 'DESC' ? '<' : '>';
    query = query.where(sortField, operator, decoded.value);
  }
  
  const orders = await query;
  const hasMore = orders.length > actualLimit;
  
  if (hasMore) {
    orders.pop(); // Remove extra item
  }
  
  const response = {
    data: orders,
    meta: {
      hasMore,
      limit: actualLimit
    }
  };
  
  if (orders.length > 0) {
    response.links = {
      self: req.originalUrl
    };
    
    if (hasMore) {
      const lastItem = orders[orders.length - 1];
      const nextCursor = encodeCursor({
        field: sortField,
        value: lastItem[sortField]
      });
      response.links.next = `${req.baseUrl}?cursor=${nextCursor}&limit=${actualLimit}&sort=${sort}`;
    }
  }
  
  res.json(response);
}

// Cursor encoding/decoding
function encodeCursor(data) {
  return Buffer.from(JSON.stringify(data)).toString('base64url');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString());
}

Filtering and Search

Support flexible filtering through query parameters:

// Flexible filtering implementation
async function listProducts(req, res) {
  const {
    category,
    minPrice,
    maxPrice,
    inStock,
    search,
    sort = '-createdAt',
    page = 1,
    perPage = 20
  } = req.query;
  
  let query = db('products');
  
  // Apply filters
  if (category) {
    query = query.where('category', category);
  }
  
  if (minPrice) {
    query = query.where('price', '>=', parseFloat(minPrice));
  }
  
  if (maxPrice) {
    query = query.where('price', '<=', parseFloat(maxPrice));
  }
  
  if (inStock !== undefined) {
    query = query.where('inStock', inStock === 'true');
  }
  
  if (search) {
    // Full-text search with ranking
    query = query
      .whereRaw(
        "to_tsvector('english', name || ' ' || description) @@ plainto_tsquery(?)",
        [search]
      )
      .orderByRaw(
        "ts_rank(to_tsvector('english', name || ' ' || description), plainto_tsquery(?)) DESC",
        [search]
      );
  }
  
  // Get total count
  const countQuery = query.clone();
  const [{ count }] = await countQuery.count('* as count');
  
  // Apply sorting and pagination
  const sortField = sort.startsWith('-') ? sort.slice(1) : sort;
  const sortOrder = sort.startsWith('-') ? 'desc' : 'asc';
  
  const products = await query
    .orderBy(sortField, sortOrder)
    .limit(perPage)
    .offset((page - 1) * perPage);
  
  res.json({
    data: products,
    meta: {
      pagination: {
        total: parseInt(count),
        page: parseInt(page),
        perPage: parseInt(perPage),
        totalPages: Math.ceil(count / perPage)
      }
    }
  });
}

API Versioning Strategies

APIs evolve, and versioning helps maintain backward compatibility while enabling improvements.

URL Path Versioning

The most explicit approach—version is visible in every request:

// Version in URL path
const v1Router = express.Router();
const v2Router = express.Router();

// V1 - Original implementation
v1Router.get('/users/:id', async (req, res) => {
  const user = await getUser(req.params.id);
  res.json({
    id: user.id,
    name: user.fullName,  // V1 uses 'name'
    email: user.email
  });
});

// V2 - Updated response format
v2Router.get('/users/:id', async (req, res) => {
  const user = await getUser(req.params.id);
  res.json({
    data: {
      id: user.id,
      firstName: user.firstName,  // V2 splits name
      lastName: user.lastName,
      email: user.email
    }
  });
});

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

Header-Based Versioning

Cleaner URLs but requires documentation:

// Version in Accept header
function versionRouter(req, res, next) {
  const acceptVersion = req.headers['accept-version'] || 
                        req.headers['api-version'] ||
                        'v2'; // Default to latest
  
  req.apiVersion = acceptVersion;
  next();
}

app.use(versionRouter);

app.get('/users/:id', async (req, res) => {
  const user = await getUser(req.params.id);
  
  if (req.apiVersion === 'v1') {
    return res.json({
      id: user.id,
      name: user.fullName,
      email: user.email
    });
  }
  
  // V2 (default)
  res.json({
    data: {
      id: user.id,
      firstName: user.firstName,
      lastName: user.lastName,
      email: user.email
    }
  });
});

Deprecation Handling

Communicate deprecations clearly through headers and documentation:

// Deprecation middleware
function deprecationWarning(message, sunsetDate) {
  return (req, res, next) => {
    res.set({
      'Deprecation': 'true',
      'Sunset': sunsetDate.toISOString(),
      'X-Deprecation-Notice': message
    });
    next();
  };
}

// Apply to deprecated endpoints
v1Router.get('/users/:id',
  deprecationWarning(
    'V1 API is deprecated. Please migrate to V2.',
    new Date('2026-06-01')
  ),
  getUserV1
);

Documentation and Developer Experience

Great documentation transforms a good API into a great one.

OpenAPI/Swagger Specification

Generate documentation from code using OpenAPI:

// Using swagger-jsdoc with Express
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');

const options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'My API',
      version: '2.0.0',
      description: 'API documentation with examples'
    },
    servers: [
      { url: 'https://api.example.com/v2' }
    ],
    components: {
      securitySchemes: {
        bearerAuth: {
          type: 'http',
          scheme: 'bearer',
          bearerFormat: 'JWT'
        }
      }
    }
  },
  apis: ['./routes/*.js']
};

const specs = swaggerJsdoc(options);
app.use('/docs', swaggerUi.serve, swaggerUi.setup(specs));

// Document endpoints with JSDoc comments
/**
 * @swagger
 * /users/{id}:
 *   get:
 *     summary: Get user by ID
 *     tags: [Users]
 *     security:
 *       - bearerAuth: []
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: string
 *         description: User ID
 *     responses:
 *       200:
 *         description: User found
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/User'
 *       404:
 *         description: User not found
 */
app.get('/users/:id', authenticate, getUser);

Request/Response Examples

Include realistic examples in documentation:

# OpenAPI example
components:
  schemas:
    CreateOrder:
      type: object
      required:
        - items
        - shippingAddress
      properties:
        items:
          type: array
          items:
            type: object
            properties:
              productId:
                type: string
              quantity:
                type: integer
                minimum: 1
        shippingAddress:
          $ref: '#/components/schemas/Address'
      example:
        items:
          - productId: "prod_123"
            quantity: 2
          - productId: "prod_456"
            quantity: 1
        shippingAddress:
          street: "123 Main St"
          city: "San Francisco"
          state: "CA"
          zip: "94105"
          country: "US"

Performance Optimization

API performance directly impacts user experience and costs.

Response Compression

Enable compression for all responses:

const compression = require('compression');

app.use(compression({
  filter: (req, res) => {
    // Don't compress already-compressed content
    if (req.headers['x-no-compression']) {
      return false;
    }
    return compression.filter(req, res);
  },
  level: 6 // Balance between speed and compression
}));

Field Selection

Let clients request only needed fields:

// Sparse fieldsets
app.get('/users', async (req, res) => {
  const fields = req.query.fields?.split(',') || ['id', 'name', 'email'];
  const allowedFields = ['id', 'name', 'email', 'role', 'createdAt'];
  
  // Validate requested fields
  const selectedFields = fields.filter(f => allowedFields.includes(f));
  
  const users = await db('users')
    .select(selectedFields)
    .limit(20);
  
  res.json({ data: users });
});

// Usage: GET /users?fields=id,name,email

Caching Headers

Implement HTTP caching for read-heavy endpoints:

app.get('/products/:id', async (req, res) => {
  const product = await getProduct(req.params.id);
  
  if (!product) {
    return res.status(404).json({ error: 'Product not found' });
  }
  
  // ETag for conditional requests
  const etag = `"${product.updatedAt.getTime()}"`;
  
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();
  }
  
  res.set({
    'ETag': etag,
    'Cache-Control': 'private, max-age=300', // 5 minutes
    'Last-Modified': product.updatedAt.toUTCString()
  });
  
  res.json({ data: product });
});

For comprehensive caching strategies, see distributed cache design.

Conclusion

Well-designed APIs are more than technical implementations—they're products that developers consume. Consistency, clear documentation, proper error handling, and attention to security create APIs that developers enjoy using and that scale reliably.

The patterns in this guide—resource-oriented design, proper authentication, cursor pagination, versioning strategies, and performance optimization—represent battle-tested approaches used in production systems handling millions of requests. Apply them systematically, and you'll build APIs that stand the test of time.

For more backend architecture patterns, explore rate limiter design, distributed cache design, and real-time collaboration. If you need help designing or implementing production APIs, contact me or check out my services. Building robust, developer-friendly APIs is my specialty.