REST API Patterns That Won't Embarrass You in Code Review

REST APIs need consistent patterns for validation, authentication, error handling, and response formatting - here's what actually works in production.

Building an Express REST API is easy. Building one that doesn't get you yelled at by senior engineers when they see your route handlers is harder. After reviewing enough APIs that made me question life choices, here are the patterns that actually work.

Request Validation - Stop Trusting User Input

Your users will send garbage. Malicious garbage, accidentally garbage, creatively garbage. Never trust request data, period. I've seen production APIs broken by users sending null where they should send strings, arrays where they should send objects, and SQL injection attempts disguised as user feedback.

Zod vs Joi vs express-validator - The Real Comparison

Here's what actually matters after using all three in production:

Zod (2025 favorite):

Joi (Production workhorse):

express-validator (Simple choice):

  • Built specifically for Express
  • Lighter learning curve
  • Less type safety but faster setup
  • Good for simple validation needs (until you need real TypeScript integration)

Zod validation that doesn't make you cry:

const { z } = require('zod');

// Define schemas that catch real-world edge cases
const CreateUserSchema = z.object({
  email: z.string()
    .email('Invalid email format')
    .max(255, 'Email too long')
    .toLowerCase()
    .trim(),
  
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .max(128, 'Password too long (max 128 chars)')
    .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 
           'Password must contain uppercase, lowercase, and number'),
  
  name: z.string()
    .min(1, 'Name is required')
    .max(100, 'Name too long')
    .regex(/^[a-zA-Z\s]+$/, 'Name can only contain letters and spaces')
    .trim(),
  
  age: z.number()
    .int('Age must be a whole number')
    .min(13, 'Must be at least 13 years old')
    .max(120, 'Invalid age')
    .optional(),
    
  // Handle arrays properly
  tags: z.array(z.string().max(50, 'Tag too long'))
    .max(10, 'Maximum 10 tags allowed')
    .optional()
    .default([])
});

// Validation middleware that doesn't suck
const validateRequest = (schema) => {
  return (req, res, next) => {
    try {
      // Parse and validate - this transforms the data too
      req.body = schema.parse(req.body);
      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        const formattedErrors = error.errors.map(err => ({
          field: err.path.join('.'),
          message: err.message,
          received: err.received
        }));
        
        return res.status(400).json({
          error: 'Validation failed',
          details: formattedErrors
        });
      }
      next(error);
    }
  };
};

// Usage - clean and type-safe
app.post('/api/users', validateRequest(CreateUserSchema), async (req, res) => {
  // req.body is now validated and transformed
  const user = await User.create(req.body);
  res.status(201).json({ 
    id: user.id, 
    email: user.email, 
    name: user.name 
  });
});

Advanced Validation Patterns

Real APIs need more than basic field validation:

// Custom validation for business rules
const UpdateUserSchema = z.object({
  email: z.string().email().optional(),
  currentPassword: z.string().optional(),
  newPassword: z.string().min(8).optional()
}).refine(data => {
  // If changing password, current password is required
  if (data.newPassword && !data.currentPassword) {
    return false;
  }
  return true;
}, {
  message: 'Current password required when changing password',
  path: ['currentPassword']
});

// File upload validation
const FileUploadSchema = z.object({
  file: z.object({
    mimetype: z.enum(['image/jpeg', 'image/png', 'image/gif'], {
      errorMap: () => ({ message: 'Only JPEG, PNG, and GIF images allowed' })
    }),
    size: z.number().max(5 * 1024 * 1024, 'File too large (max 5MB)')
  })
});

// Query parameter validation  
const GetUsersSchema = z.object({
  page: z.string()
    .regex(/^\d+$/, 'Page must be a number')
    .transform(Number)
    .refine(val => val >= 1, 'Page must be at least 1')
    .optional()
    .default('1'),
    
  limit: z.string()
    .regex(/^\d+$/, 'Limit must be a number')
    .transform(Number)
    .refine(val => val >= 1 && val <= 100, 'Limit must be between 1 and 100')
    .optional()
    .default('20'),
    
  search: z.string()
    .min(2, 'Search must be at least 2 characters')
    .max(100, 'Search too long')
    .trim()
    .optional()
});

app.get('/api/users', validateQuery(GetUsersSchema), async (req, res) => {
  const { page, limit, search } = req.query;
  const users = await User.findPaginated({ page, limit, search });
  res.json(users);
});

More validation patterns? The Zod docs and Express validation guides have some decent examples.

JWT Authentication That Doesn't Leak Secrets

JWT authentication involves access tokens, refresh tokens, secure storage, and proper validation - get any part wrong and you'll leak user data.

JWT authentication is everywhere because it's stateless and scales well. It's also everywhere because developers implement it wrong and create security disasters. Here's how to do JWT auth without getting fired:

Secure JWT Implementation

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

// Rate limiting for auth endpoints - critical for security
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts per window
  message: 'Too many login attempts, try again later',
  standardHeaders: true,
  legacyHeaders: false
});

// Secure JWT configuration
const jwtConfig = {
  accessTokenSecret: process.env.JWT_ACCESS_SECRET, // Strong random secret
  refreshTokenSecret: process.env.JWT_REFRESH_SECRET, // Different secret
  accessTokenExpiry: '15m', // Short-lived access tokens
  refreshTokenExpiry: '7d' // Longer refresh tokens
};

// Token generation with proper claims
const generateTokens = (user) => {
  const payload = {
    sub: user.id.toString(), // Subject - user ID
    email: user.email,
    role: user.role,
    iat: Math.floor(Date.now() / 1000), // Issued at
    iss: 'your-api-name' // Issuer
  };

  const accessToken = jwt.sign(payload, jwtConfig.accessTokenSecret, {
    expiresIn: jwtConfig.accessTokenExpiry,
    algorithm: 'HS256'
  });

  const refreshToken = jwt.sign(
    { sub: user.id.toString(), type: 'refresh' }, 
    jwtConfig.refreshTokenSecret, 
    {
      expiresIn: jwtConfig.refreshTokenExpiry,
      algorithm: 'HS256'
    }
  );

  return { accessToken, refreshToken };
};

// Secure login endpoint
app.post('/api/auth/login', authLimiter, async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Find user and include password for verification
    // Fun fact: Sequelize will cache this query and fuck you over in production
    const user = await User.findOne({ 
      where: { email: email.toLowerCase() },
      include: ['password_hash'] 
    });

    if (!user || !await bcrypt.compare(password, user.password_hash)) {
      // Don't reveal whether email exists
      return res.status(401).json({ 
        error: 'Invalid email or password' 
      });
    }

    // Check if account is active
    if (!user.is_active) {
      return res.status(401).json({ 
        error: 'Account is disabled' 
      });
    }

    const { accessToken, refreshToken } = generateTokens(user);

    // Store refresh token hash in database
    const refreshTokenHash = await bcrypt.hash(refreshToken, 10);
    await user.update({ 
      refresh_token_hash: refreshTokenHash,
      last_login: new Date()
    });

    // Set secure HTTP-only cookie for refresh token
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
    });

    // Return access token in response body
    res.json({
      accessToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        role: user.role
      },
      expiresIn: jwtConfig.accessTokenExpiry
    });

  } catch (error) {
    console.error('Login error:', error);
    res.status(500).json({ error: 'Login failed' });
  }
});

JWT Authentication Middleware

// JWT verification middleware with proper error handling
const authenticateJWT = (options = {}) => {
  return async (req, res, next) => {
    try {
      // Get token from Authorization header
      const authHeader = req.headers.authorization;
      const token = authHeader && authHeader.startsWith('Bearer ') 
        ? authHeader.slice(7) 
        : null;

      if (!token) {
        if (options.optional) {
          req.user = null;
          return next();
        }
        return res.status(401).json({ 
          error: 'Access token required',
          code: 'TOKEN_MISSING'
        });
      }

      // Verify token
      const decoded = jwt.verify(token, jwtConfig.accessTokenSecret);
      
      // Optional: Check if user still exists and is active
      if (options.checkUser) {
        const user = await User.findByPk(decoded.sub);
        if (!user || !user.is_active) {
          return res.status(401).json({ 
            error: 'Invalid token',
            code: 'USER_NOT_FOUND'
          });
        }
        req.user = user;
      } else {
        req.user = decoded;
      }

      next();

    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        return res.status(401).json({ 
          error: 'Token expired',
          code: 'TOKEN_EXPIRED'
        });
      }
      
      if (error.name === 'JsonWebTokenError') {
        return res.status(401).json({ 
          error: 'Invalid token',
          code: 'TOKEN_INVALID'
        });
      }

      console.error('JWT verification error:', error);
      res.status(500).json({ error: 'Authentication failed' });
    }
  };
};

// Authorization middleware for role-based access
const requireRole = (roles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    const userRoles = Array.isArray(req.user.role) ? req.user.role : [req.user.role];
    const requiredRoles = Array.isArray(roles) ? roles : [roles];
    
    const hasRole = requiredRoles.some(role => userRoles.includes(role));
    
    if (!hasRole) {
      return res.status(403).json({ 
        error: 'Insufficient permissions',
        required: requiredRoles,
        current: userRoles
      });
    }

    next();
  };
};

// Usage examples
app.get('/api/profile', 
  authenticateJWT({ checkUser: true }), 
  getUserProfile
);

app.delete('/api/users/:id', 
  authenticateJWT({ checkUser: true }), 
  requireRole(['admin', 'moderator']), 
  deleteUser
);

app.get('/api/public', 
  authenticateJWT({ optional: true }), 
  getPublicData // Works with or without auth
);

Token Refresh Pattern

// Token refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
  try {
    const refreshToken = req.cookies.refreshToken;
    
    if (!refreshToken) {
      return res.status(401).json({ 
        error: 'Refresh token required',
        code: 'REFRESH_TOKEN_MISSING'
      });
    }

    // Verify refresh token
    const decoded = jwt.verify(refreshToken, jwtConfig.refreshTokenSecret);
    
    // Find user and verify stored refresh token
    const user = await User.findByPk(decoded.sub);
    if (!user || !user.refresh_token_hash) {
      return res.status(401).json({ 
        error: 'Invalid refresh token',
        code: 'REFRESH_TOKEN_INVALID'
      });
    }

    // Verify refresh token hash
    const isValidRefresh = await bcrypt.compare(refreshToken, user.refresh_token_hash);
    if (!isValidRefresh) {
      return res.status(401).json({ 
        error: 'Invalid refresh token',
        code: 'REFRESH_TOKEN_INVALID'
      });
    }

    // Generate new tokens
    const { accessToken, refreshToken: newRefreshToken } = generateTokens(user);

    // Update stored refresh token
    const newRefreshTokenHash = await bcrypt.hash(newRefreshToken, 10);
    await user.update({ refresh_token_hash: newRefreshTokenHash });

    // Set new refresh token cookie
    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    res.json({
      accessToken,
      expiresIn: jwtConfig.accessTokenExpiry
    });

  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ 
        error: 'Refresh token expired',
        code: 'REFRESH_TOKEN_EXPIRED'
      });
    }

    console.error('Token refresh error:', error);
    res.status(500).json({ error: 'Token refresh failed' });
  }
});

// Logout endpoint - invalidate refresh token
app.post('/api/auth/logout', authenticateJWT(), async (req, res) => {
  try {
    const user = await User.findByPk(req.user.sub);
    if (user) {
      await user.update({ refresh_token_hash: null });
    }

    res.clearCookie('refreshToken');
    res.json({ message: 'Logged out successfully' });

  } catch (error) {
    console.error('Logout error:', error);
    res.status(500).json({ error: 'Logout failed' });
  }
});

Want to learn more about not fucking up JWT? Auth0's security best practices, Node.js JWT security guide, and OWASP JWT guidelines are solid.

Error Handling That Actually Helps Debugging

Bad error responses waste hours of debugging time. Good error responses prevent support tickets and angry users. Here's how to build error handling that helps instead of hurts:

Structured Error Responses

// Error response factory
const createErrorResponse = (type, message, details = null, code = null) => {
  const error = {
    error: message,
    type,
    timestamp: new Date().toISOString(),
    requestId: req.requestId || 'unknown'
  };

  if (code) error.code = code;
  if (details && process.env.NODE_ENV !== 'production') {
    error.details = details;
  }

  return error;
};

// Centralized error handling middleware
const errorHandler = (err, req, res, next) => {
  // Log error with context
  console.error('API Error:', {
    error: err.message,
    stack: err.stack,
    requestId: req.requestId,
    url: req.originalUrl,
    method: req.method,
    body: req.body,
    params: req.params,
    query: req.query,
    userAgent: req.get('User-Agent'),
    ip: req.ip,
    timestamp: new Date().toISOString()
  });

  // Handle different error types
  if (err.name === 'ValidationError') {
    return res.status(400).json(
      createErrorResponse('VALIDATION_ERROR', 'Invalid request data', err.details)
    );
  }

  if (err.name === 'UnauthorizedError') {
    return res.status(401).json(
      createErrorResponse('UNAUTHORIZED', 'Authentication failed', null, 'AUTH_FAILED')
    );
  }

  if (err.name === 'ForbiddenError') {
    return res.status(403).json(
      createErrorResponse('FORBIDDEN', 'Insufficient permissions', null, 'PERMISSION_DENIED')
    );
  }

  if (err.name === 'NotFoundError') {
    return res.status(404).json(
      createErrorResponse('NOT_FOUND', 'Resource not found', null, 'RESOURCE_NOT_FOUND')
    );
  }

  if (err.code === 'ECONNREFUSED') {
    return res.status(503).json(
      createErrorResponse('SERVICE_UNAVAILABLE', 'Database connection failed', null, 'DB_UNAVAILABLE')
    );
  }

  // Database constraint violations
  if (err.name === 'SequelizeUniqueConstraintError') {
    return res.status(409).json(
      createErrorResponse('CONFLICT', 'Resource already exists', err.errors)
    );
  }

  if (err.name === 'SequelizeForeignKeyConstraintError') {
    return res.status(400).json(
      createErrorResponse('INVALID_REFERENCE', 'Referenced resource does not exist')
    );
  }

  // Default internal server error
  res.status(500).json(
    createErrorResponse(
      'INTERNAL_ERROR', 
      process.env.NODE_ENV === 'production' 
        ? 'Internal server error' 
        : err.message
    )
  );
};

// Custom error classes for better error handling
class APIError extends Error {
  constructor(message, statusCode = 500, type = 'API_ERROR', code = null) {
    super(message);
    this.name = 'APIError';
    this.statusCode = statusCode;
    this.type = type;
    this.code = code;
  }
}

class ValidationError extends APIError {
  constructor(message, details = null) {
    super(message, 400, 'VALIDATION_ERROR');
    this.name = 'ValidationError';
    this.details = details;
  }
}

class NotFoundError extends APIError {
  constructor(resource = 'Resource') {
    super(`${resource} not found`, 404, 'NOT_FOUND', 'RESOURCE_NOT_FOUND');
    this.name = 'NotFoundError';
  }
}

class UnauthorizedError extends APIError {
  constructor(message = 'Authentication required') {
    super(message, 401, 'UNAUTHORIZED', 'AUTH_REQUIRED');
    this.name = 'UnauthorizedError';
  }
}

// Usage in route handlers
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findByPk(req.params.id);
    if (!user) {
      throw new NotFoundError('User');
    }
    res.json(user);
  } catch (error) {
    next(error);
  }
});

app.post('/api/posts', authenticateJWT(), async (req, res, next) => {
  try {
    if (!req.body.title || req.body.title.trim().length === 0) {
      throw new ValidationError('Title is required', { field: 'title' });
    }

    const post = await Post.create({
      ...req.body,
      userId: req.user.sub
    });
    
    res.status(201).json(post);
  } catch (error) {
    next(error);
  }
});

More error handling patterns? The Express error guide, Node.js error docs, and JSON API error standards have decent examples.

REST API Structure That Scales

Good API structure prevents technical debt and makes your API pleasant to use. Bad structure leads to inconsistent endpoints, duplicated logic, and developers cursing your name:

Resource-Based URL Design

// Good REST URL patterns
GET    /api/users              // List users
POST   /api/users              // Create user
GET    /api/users/:id          // Get specific user
PUT    /api/users/:id          // Update user (full replacement)
PATCH  /api/users/:id          // Partial update user
DELETE /api/users/:id          // Delete user

// Nested resources
GET    /api/users/:id/posts    // User's posts
POST   /api/users/:id/posts    // Create post for user
GET    /api/posts/:id/comments // Comments for a post

// Avoid deep nesting (> 2 levels)
// Bad: /api/users/:id/posts/:postId/comments/:commentId/replies
// Good: /api/comments/:id/replies

// Query parameters for filtering/sorting
GET /api/posts?author=123&published=true&sort=-created_at&page=2&limit=20

Consistent Response Formats

// Success responses with metadata
const sendSuccessResponse = (res, data, metadata = {}) => {
  const response = {
    success: true,
    data,
    ...metadata
  };

  if (metadata.pagination) {
    response.pagination = metadata.pagination;
  }

  res.json(response);
};

// Paginated list responses
app.get('/api/posts', async (req, res, next) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 20;
    const offset = (page - 1) * limit;

    const { rows: posts, count: totalItems } = await Post.findAndCountAll({
      limit,
      offset,
      order: [['created_at', 'DESC']],
      include: [{ model: User, attributes: ['id', 'name'] }]
    });

    sendSuccessResponse(res, posts, {
      pagination: {
        currentPage: page,
        totalItems,
        totalPages: Math.ceil(totalItems / limit),
        itemsPerPage: limit,
        hasNextPage: page < Math.ceil(totalItems / limit),
        hasPreviousPage: page > 1
      }
    });
  } catch (error) {
    next(error);
  }
});

// Single resource response
app.get('/api/users/:id', async (req, res, next) => {
  try {
    const user = await User.findByPk(req.params.id, {
      attributes: { exclude: ['password_hash'] }
    });
    
    if (!user) {
      throw new NotFoundError('User');
    }

    sendSuccessResponse(res, user);
  } catch (error) {
    next(error);
  }
});

// Creation response with location header
app.post('/api/users', async (req, res, next) => {
  try {
    const user = await User.create(req.body);
    
    res.status(201)
       .location(`/api/users/${user.id}`)
       .json({
         success: true,
         data: user,
         message: 'User created successfully'
       });
  } catch (error) {
    next(error);
  }
});

That covers the main patterns for building Express APIs that won't embarrass you. Focus on consistency, don't leak errors, secure your auth properly, and validate everything users send you because they will definitely try to break your shit.

API Development Approaches - What Works in Practice

Approach

Best For

Learning Curve

Production Reality

Type Safety

Express + Zod

TypeScript apps, type-safe validation

🔧🔧

Modern choice

  • runtime type safety matches TS

⭐⭐⭐⭐⭐

Express + Joi

Battle-tested APIs, rich validation

🔧🔧🔧

Production workhorse

  • 8M+ weekly downloads

⭐⭐⭐

Express + express-validator

Simple APIs, quick setup

🔧

Good for basic validation, less type safety

⭐⭐

NestJS + class-validator

Enterprise apps, Angular-style architecture

🔧🔧🔧🔧

Heavy but powerful

  • great for large teams

⭐⭐⭐⭐⭐

Fastify + Fastify schemas

High-performance APIs, JSON schema

🔧🔧🔧

Fastest but smaller ecosystem

⭐⭐⭐⭐

Express + OpenAPI

API-first development, documentation

🔧🔧🔧🔧

Great for API contracts, complex setup

⭐⭐⭐⭐

API Development Questions That Show Up in Every Code Review

Q

How do I handle file uploads in Express APIs without killing my server?

A

Never buffer file uploads in memory. Stream directly to disk or cloud storage:

const multer = require('multer');
const AWS = require('aws-sdk');

// Stream to disk first, then to S3
const upload = multer({
  dest: '/tmp/uploads',
  limits: {
    fileSize: 10 * 1024 * 1024, // 10MB
    files: 1
  },
  fileFilter: (req, file, cb) => {
    // Validate file types
    const allowedTypes = /jpeg|jpg|png|gif/;
    const mimeType = allowedTypes.test(file.mimetype);
    
    if (mimeType) {
      return cb(null, true);
    } else {
      cb(new Error('Invalid file type. Only JPEG, PNG, and GIF allowed.'));
    }
  }
});

app.post('/api/upload', upload.single('file'), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  try {
    // Stream to S3 
    const s3 = new AWS.S3();
    const uploadResult = await s3.upload({
      Bucket: process.env.S3_BUCKET,
      Key: `uploads/${Date.now()}-${req.file.originalname}`,
      Body: fs.createReadStream(req.file.path),
      ContentType: req.file.mimetype
    }).promise();

    // Clean up temp file
    fs.unlink(req.file.path, (err) => {
      if (err) console.error('Failed to delete temp file:', err);
    });

    res.json({ url: uploadResult.Location });
  } catch (error) {
    console.error('Upload failed:', error);
    res.status(500).json({ error: 'Upload failed' });
  }
});
Q

Should I use Zod or Joi for API validation in 2025?

A

Zod if you're using TypeScript, Joi if you're not. Zod's type inference is incredible:

// Zod - types are inferred automatically
const UserSchema = z.object({
  email: z.string().email(),
  age: z.number().min(13)
});

type User = z.infer<typeof UserSchema>; // { email: string, age: number }

// Joi - need separate type definitions
const UserJoiSchema = Joi.object({
  email: Joi.string().email().required(),
  age: Joi.number().min(13).required()
});

interface User {
  email: string;
  age: number;
}

Zod has 40% smaller bundle size and growing fast. Joi is more battle-tested with 8M+ weekly downloads. Both work fine in production.

Q

How do I implement pagination that doesn't suck?

A

Use cursor-based pagination for large datasets, offset-based for small ones:

// Offset-based (simple but doesn't scale well)
app.get('/api/posts', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = Math.min(parseInt(req.query.limit) || 20, 100); // Cap at 100
  const offset = (page - 1) * limit;

  const { rows: posts, count } = await Post.findAndCountAll({
    limit,
    offset,
    order: [['created_at', 'DESC']]
  });

  res.json({
    data: posts,
    pagination: {
      page,
      limit,
      total: count,
      pages: Math.ceil(count / limit),
      hasNext: page < Math.ceil(count / limit),
      hasPrev: page > 1
    }
  });
});

// Cursor-based (scales better for large datasets)
app.get('/api/posts/cursor', async (req, res) => {
  const limit = Math.min(parseInt(req.query.limit) || 20, 100);
  const cursor = req.query.cursor;

  const whereClause = cursor ? {
    created_at: { [Op.lt]: new Date(cursor) }
  } : {};

  const posts = await Post.findAll({
    where: whereClause,
    limit: limit + 1, // Get one extra to check if there's a next page
    order: [['created_at', 'DESC']]
  });

  const hasNext = posts.length > limit;
  if (hasNext) posts.pop(); // Remove the extra record

  const nextCursor = hasNext && posts.length > 0 
    ? posts[posts.length - 1].created_at.toISOString() 
    : null;

  res.json({
    data: posts,
    pagination: {
      limit,
      hasNext,
      nextCursor
    }
  });
});
Q

How do I version my API without breaking everything?

A

URL versioning is simple and works:

// Version in URL (recommended)
app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);

// Header versioning (more RESTful but harder to test)
app.use('/api', (req, res, next) => {
  const version = req.headers['api-version'] || 'v1';
  req.apiVersion = version;
  next();
});

// Gradual migration pattern
app.get('/api/v1/users', async (req, res) => {
  const users = await User.findAll();
  res.json(users); // Old format
});

app.get('/api/v2/users', async (req, res) => {
  const users = await User.findAll();
  // New format with additional fields
  const transformedUsers = users.map(user => ({
    id: user.id,
    profile: {
      email: user.email,
      name: user.name,
      avatar: user.avatar_url
    },
    metadata: {
      created_at: user.created_at,
      updated_at: user.updated_at
    }
  }));
  res.json({ users: transformedUsers });
});
Q

What's the best way to handle API rate limiting?

A

Use Redis for distributed rate limiting, in-memory for single instances:

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

// Redis-based (for multiple servers)
const redisClient = Redis.createClient(process.env.REDIS_URL);

const apiLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rate_limit:'
  }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  message: {
    error: 'Too many requests',
    retryAfter: '15 minutes'
  },
  standardHeaders: true
});

// Different limits for different endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // Stricter for auth
  skipSuccessfulRequests: true // Only count failed attempts
});

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

// IP whitelist for internal services
const createRateLimiter = (options) => {
  return rateLimit({
    ...options,
    skip: (req) => {
      const whitelist = process.env.RATE_LIMIT_WHITELIST?.split(',') || [];
      return whitelist.includes(req.ip);
    }
  });
};
Q

How do I test Express APIs properly?

A

Use Supertest for HTTP-level testing, don't mock Express internals:

const request = require('supertest');
const app = require('../app');

describe('POST /api/users', () => {
  beforeEach(async () => {
    // Clean database before each test
    await User.destroy({ where: {} });
  });

  it('should create user with valid data', async () => {
    const userData = {
      email: 'test@example.com',
      password: 'StrongPass123',
      name: 'Test User'
    };

    const response = await request(app)
      .post('/api/users')
      .send(userData)
      .expect(201);

    expect(response.body.data).toHaveProperty('id');
    expect(response.body.data.email).toBe(userData.email);
    expect(response.body.data).not.toHaveProperty('password');
  });

  it('should reject invalid email', async () => {
    const userData = {
      email: 'invalid-email',
      password: 'StrongPass123',
      name: 'Test User'
    };

    const response = await request(app)
      .post('/api/users')
      .send(userData)
      .expect(400);

    expect(response.body.error).toBe('Validation failed');
    expect(response.body.details).toContainEqual({
      field: 'email',
      message: 'Invalid email format'
    });
  });

  it('should require authentication for protected endpoint', async () => {
    await request(app)
      .get('/api/profile')
      .expect(401);
  });

  it('should work with valid JWT token', async () => {
    const user = await User.create({
      email: 'test@example.com',
      password: 'hashedpass'
    });

    const token = jwt.sign(
      { sub: user.id, email: user.email }, 
      process.env.JWT_SECRET
    );

    await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);
  });
});

// Integration test with real database
describe('API Integration', () => {
  it('should handle complete user flow', async () => {
    // Register user
    const registerResponse = await request(app)
      .post('/api/auth/register')
      .send({
        email: 'integration@test.com',
        password: 'TestPass123',
        name: 'Integration User'
      })
      .expect(201);

    // Login
    const loginResponse = await request(app)
      .post('/api/auth/login')
      .send({
        email: 'integration@test.com',
        password: 'TestPass123'
      })
      .expect(200);

    const token = loginResponse.body.accessToken;

    // Use protected endpoint
    const profileResponse = await request(app)
      .get('/api/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);

    expect(profileResponse.body.data.email).toBe('integration@test.com');
  });
});
Q

How do I handle CORS properly in Express APIs?

A

Configure CORS based on your frontend requirements:

const cors = require('cors');

// Development - allow everything
if (process.env.NODE_ENV === 'development') {
  app.use(cors());
} else {
  // Production - strict CORS
  app.use(cors({
    origin: [
      'https://yourdomain.com',
      'https://www.yourdomain.com',
      'https://app.yourdomain.com'
    ],
    credentials: true, // Allow cookies
    methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    allowedHeaders: [
      'Content-Type',
      'Authorization',
      'X-Requested-With',
      'Accept',
      'Origin'
    ],
    exposedHeaders: ['X-Total-Count', 'X-Rate-Limit-Remaining']
  }));
}

// Handle preflight requests
app.options('*', cors());
Q

Should I use GraphQL or REST for my API?

A

REST for most cases, GraphQL for complex data relationships:

Use REST when:

  • Building simple CRUD operations
  • Team is familiar with REST patterns
  • Need simple caching strategies
  • Mobile app with limited query complexity

Use GraphQL when:

  • Complex data relationships and nested queries
  • Multiple clients with different data needs
  • Team comfortable with GraphQL complexity
  • Real-time features with subscriptions

Hybrid approach (often best):

// REST for simple operations
app.get('/api/users', getUsers);
app.post('/api/users', createUser);

// GraphQL for complex queries
app.use('/graphql', graphqlHTTP({
  schema: myGraphQLSchema,
  graphiql: process.env.NODE_ENV === 'development'
}));
Q

How do I structure my Express API project?

A

Use feature-based organization for larger APIs:

src/
├── config/
│   ├── database.js
│   └── auth.js
├── middleware/
│   ├── auth.js
│   ├── validation.js
│   └── errorHandler.js
├── models/
│   ├── User.js
│   └── Post.js
├── routes/
│   ├── auth.js
│   ├── users.js
│   └── posts.js
├── services/
│   ├── userService.js
│   └── emailService.js
├── utils/
│   ├── jwt.js
│   └── helpers.js
├── tests/
│   ├── auth.test.js
│   └── users.test.js
└── app.js

Or domain-based for microservices:

src/
├── auth/
│   ├── authController.js
│   ├── authService.js
│   ├── authRoutes.js
│   └── authMiddleware.js
├── users/
│   ├── userController.js
│   ├── userService.js
│   └── userRoutes.js
├── shared/
│   ├── database.js
│   ├── validation.js
│   └── errorHandler.js
└── app.js
Q

How do I debug slow API endpoints?

A

Profile with Node.js tools and add request timing:

// Request timing middleware
app.use((req, res, next) => {
  req.startTime = Date.now();
  
  res.on('finish', () => {
    const duration = Date.now() - req.startTime;
    
    if (duration > 1000) { // Log slow requests
      console.warn(`Slow request: ${req.method} ${req.path} - ${duration}ms`);
    }
    
    // Add timing header for debugging
    res.set('X-Response-Time', `${duration}ms`);
  });
  
  next();
});

// Database query timing
const timedQuery = async (query, params) => {
  const start = Date.now();
  const result = await db.query(query, params);
  const duration = Date.now() - start;
  
  if (duration > 100) {
    console.warn(`Slow query (${duration}ms):`, query.substring(0, 100));
  }
  
  return result;
};

// Use clinic.js for detailed profiling
// npm install -g clinic
// clinic doctor -- node app.js
// clinic flame -- node app.js
Q

How do I implement search in my API?

A

Start with database search, move to dedicated search for complex needs:

// Simple database search
app.get('/api/search', async (req, res) => {
  const { q, type } = req.query;
  
  if (!q || q.length < 2) {
    return res.status(400).json({ error: 'Search query too short' });
  }

  const results = {};

  if (!type || type === 'users') {
    results.users = await User.findAll({
      where: {
        [Op.or]: [
          { name: { [Op.iLike]: `%${q}%` } },
          { email: { [Op.iLike]: `%${q}%` } }
        ]
      },
      limit: 10
    });
  }

  if (!type || type === 'posts') {
    results.posts = await Post.findAll({
      where: {
        [Op.or]: [
          { title: { [Op.iLike]: `%${q}%` } },
          { content: { [Op.iLike]: `%${q}%` } }
        ]
      },
      limit: 10
    });
  }

  res.json({ query: q, results });
});

// Advanced search with Elasticsearch
const { Client } = require('@elastic/elasticsearch');
const client = new Client({ node: process.env.ELASTICSEARCH_URL });

app.get('/api/advanced-search', async (req, res) => {
  const { q, filters = {} } = req.query;

  const searchQuery = {
    index: 'content',
    body: {
      query: {
        bool: {
          must: [
            {
              multi_match: {
                query: q,
                fields: ['title^2', 'content', 'tags']
              }
            }
          ],
          filter: Object.entries(filters).map(([key, value]) => ({
            term: { [key]: value }
          }))
        }
      },
      highlight: {
        fields: {
          title: {},
          content: {}
        }
      }
    }
  };

  const results = await client.search(searchQuery);
  res.json(results.body.hits);
});
Q

My API is getting hammered by bots - how do I protect it?

A

Implement multiple layers of protection:

const rateLimit = require('express-rate-limit');
const slowDown = require('express-slow-down');
const helmet = require('helmet');

// Basic security headers
app.use(helmet());

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  message: 'Too many requests'
});
app.use('/api/', limiter);

// Progressive delays for suspicious activity
const speedLimiter = slowDown({
  windowMs: 15 * 60 * 1000,
  delayAfter: 50, // Allow 50 requests per 15 minutes without delay
  delayMs: 500 // Add 500ms delay per request after delayAfter
});
app.use('/api/', speedLimiter);

// Bot detection middleware
const detectBots = (req, res, next) => {
  const userAgent = req.get('User-Agent') || '';
  
  // Block common bot patterns
  const botPatterns = [
    /curl/i,
    /wget/i,
    /python/i,
    /scrapy/i,
    /bot/i
  ];
  
  const isBot = botPatterns.some(pattern => pattern.test(userAgent));
  
  if (isBot && !req.path.startsWith('/api/public')) {
    return res.status(403).json({ error: 'Automated requests not allowed' });
  }
  
  next();
};

app.use(detectBots);

// Captcha verification for suspicious requests
app.post('/api/auth/register', async (req, res) => {
  // Verify captcha for new registrations
  if (process.env.NODE_ENV === 'production') {
    const captchaResponse = req.body.captcha;
    const isValidCaptcha = await verifyCaptcha(captchaResponse, req.ip);
    
    if (!isValidCaptcha) {
      return res.status(400).json({ error: 'Captcha verification failed' });
    }
  }
  
  // Proceed with registration...
});

Express API Development Resources That Actually Help

Related Tools & Recommendations

review
Recommended

Which JavaScript Runtime Won't Make You Hate Your Life

Two years of runtime fuckery later, here's the truth nobody tells you

Bun
/review/bun-nodejs-deno-comparison/production-readiness-assessment
100%
compare
Recommended

PostgreSQL vs MySQL vs MongoDB vs Cassandra - Which Database Will Ruin Your Weekend Less?

Skip the bullshit. Here's what breaks in production.

PostgreSQL
/compare/postgresql/mysql/mongodb/cassandra/comprehensive-database-comparison
64%
howto
Recommended

Install Node.js with NVM on Mac M1/M2/M3 - Because Life's Too Short for Version Hell

My M1 Mac setup broke at 2am before a deployment. Here's how I fixed it so you don't have to suffer.

Node Version Manager (NVM)
/howto/install-nodejs-nvm-mac-m1/complete-installation-guide
55%
integration
Recommended

Claude API Code Execution Integration - Advanced Tools Guide

Build production-ready applications with Claude's code execution and file processing tools

Claude API
/integration/claude-api-nodejs-express/advanced-tools-integration
55%
pricing
Recommended

Vercel vs Netlify vs Cloudflare Workers Pricing: Why Your Bill Might Surprise You

Real costs from someone who's been burned by hosting bills before

Vercel
/pricing/vercel-vs-netlify-vs-cloudflare-workers/total-cost-analysis
53%
pricing
Recommended

What Enterprise Platform Pricing Actually Looks Like When the Sales Gloves Come Off

Vercel, Netlify, and Cloudflare Pages: The Real Costs Behind the Marketing Bullshit

Vercel
/pricing/vercel-netlify-cloudflare-enterprise-comparison/enterprise-cost-analysis
53%
troubleshoot
Recommended

Fix Kubernetes Service Not Accessible - Stop the 503 Hell

Your pods show "Running" but users get connection refused? Welcome to Kubernetes networking hell.

Kubernetes
/troubleshoot/kubernetes-service-not-accessible/service-connectivity-troubleshooting
50%
tool
Recommended

MongoDB Atlas Enterprise Deployment Guide

integrates with MongoDB Atlas

MongoDB Atlas
/tool/mongodb-atlas/enterprise-deployment
36%
alternatives
Recommended

Your MongoDB Atlas Bill Just Doubled Overnight. Again.

integrates with MongoDB Atlas

MongoDB Atlas
/alternatives/mongodb-atlas/migration-focused-alternatives
36%
howto
Recommended

MySQL to PostgreSQL Production Migration: Complete Step-by-Step Guide

Migrate MySQL to PostgreSQL without destroying your career (probably)

MySQL
/howto/migrate-mysql-to-postgresql-production/mysql-to-postgresql-production-migration
36%
howto
Recommended

I Survived Our MongoDB to PostgreSQL Migration - Here's How You Can Too

Four Months of Pain, 47k Lost Sessions, and What Actually Works

MongoDB
/howto/migrate-mongodb-to-postgresql/complete-migration-guide
36%
alternatives
Recommended

Redis Alternatives for High-Performance Applications

The landscape of in-memory databases has evolved dramatically beyond Redis

Redis
/alternatives/redis/performance-focused-alternatives
36%
compare
Recommended

Redis vs Memcached vs Hazelcast: Production Caching Decision Guide

Three caching solutions that tackle fundamentally different problems. Redis 8.2.1 delivers multi-structure data operations with memory complexity. Memcached 1.6

Redis
/compare/redis/memcached/hazelcast/comprehensive-comparison
36%
tool
Recommended

Redis - In-Memory Data Platform for Real-Time Applications

The world's fastest in-memory database, providing cloud and on-premises solutions for caching, vector search, and NoSQL databases that seamlessly fit into any t

Redis
/tool/redis/overview
36%
troubleshoot
Recommended

Docker Won't Start on Windows 11? Here's How to Fix That Garbage

Stop the whale logo from spinning forever and actually get Docker working

Docker Desktop
/troubleshoot/docker-daemon-not-running-windows-11/daemon-startup-issues
33%
howto
Recommended

Stop Docker from Killing Your Containers at Random (Exit Code 137 Is Not Your Friend)

Three weeks into a project and Docker Desktop suddenly decides your container needs 16GB of RAM to run a basic Node.js app

Docker Desktop
/howto/setup-docker-development-environment/complete-development-setup
33%
news
Recommended

Docker Desktop's Stupidly Simple Container Escape Just Owned Everyone

integrates with Technology News Aggregation

Technology News Aggregation
/news/2025-08-26/docker-cve-security
33%
tool
Recommended

Google Kubernetes Engine (GKE) - Google's Managed Kubernetes (That Actually Works Most of the Time)

Google runs your Kubernetes clusters so you don't wake up to etcd corruption at 3am. Costs way more than DIY but beats losing your weekend to cluster disasters.

Google Kubernetes Engine (GKE)
/tool/google-kubernetes-engine/overview
33%
integration
Recommended

Jenkins + Docker + Kubernetes: How to Deploy Without Breaking Production (Usually)

The Real Guide to CI/CD That Actually Works

Jenkins
/integration/jenkins-docker-kubernetes/enterprise-ci-cd-pipeline
33%
integration
Recommended

Automate Your SSL Renewals Before You Forget and Take Down Production

NGINX + Certbot Integration: Because Expired Certificates at 3AM Suck

NGINX
/integration/nginx-certbot/overview
33%

Recommendations combine user behavior, content similarity, research intelligence, and SEO optimization