Backend

Building Production-Ready REST APIs with Node.js and Express

A comprehensive guide to building secure, scalable, and maintainable REST APIs using Node.js, Express, TypeScript, and industry best practices.

Bahaa AbbasWednesday, December 3, 202514 min read
Building Production-Ready REST APIs with Node.js and Express

Introduction

Building a production-ready API requires more than just setting up Express routes. You need proper error handling, validation, authentication, logging, rate limiting, and more. In this comprehensive guide, we'll build a REST API following industry best practices that's ready for production deployment. We'll cover everything from project structure to security considerations, ensuring your API is robust, scalable, and maintainable.

Project Structure and Setup

A well-organized project structure is crucial for maintainability. We'll use a layered architecture separating concerns into routes, controllers, services, and data access layers. This separation makes testing easier and keeps code organized as your application grows. TypeScript provides type safety, catching errors at compile time rather than runtime.

Project Structure
src/
├── controllers/     # Request handlers
├── services/        # Business logic
├── models/          # Database models
├── middleware/      # Custom middleware
├── routes/          # Route definitions
├── utils/           # Helper functions
├── config/          # Configuration
├── types/           # TypeScript types
└── index.ts         # Entry point

// Example Controller
// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/user.service';

export class UserController {
  constructor(private userService: UserService) {}
  
  async getUser(req: Request, res: Response, next: NextFunction) {
    try {
      const user = await this.userService.findById(req.params.id);
      res.json({ success: true, data: user });
    } catch (error) {
      next(error);
    }
  }
}

Request Validation with Zod

Never trust client input. Use validation libraries like Zod to validate and type-check incoming requests. Zod provides runtime validation with excellent TypeScript integration, catching invalid data before it reaches your business logic. This prevents common security vulnerabilities and improves error messages for clients.

Validation Middleware
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';

// Define schema
const createUserSchema = z.object({
  body: z.object({
    email: z.string().email(),
    password: z.string().min(8),
    name: z.string().min(2).max(100),
  }),
});

// Validation middleware
export const validate = (schema: z.AnyZodObject) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      await schema.parseAsync({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        return res.status(400).json({
          success: false,
          errors: error.errors,
        });
      }
      next(error);
    }
  };
};

// Usage in routes
router.post('/users', 
  validate(createUserSchema),
  userController.create
);

Error Handling

Proper error handling is critical for production APIs. Implement a global error handler that catches all errors, logs them appropriately, and returns consistent error responses. Use custom error classes for different error types (ValidationError, UnauthorizedError, etc.) to make error handling more granular and informative.

Error Handler
// Custom error classes
export class AppError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public isOperational = true
  ) {
    super(message);
    Object.setPrototypeOf(this, AppError.prototype);
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string) {
    super(404, `${resource} not found`);
  }
}

// Global error handler
export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      success: false,
      message: err.message,
    });
  }
  
  // Log unexpected errors
  console.error('Unexpected error:', err);
  
  return res.status(500).json({
    success: false,
    message: 'Internal server error',
  });
};

Authentication and Authorization

Implement JWT-based authentication for stateless API authentication. Use bcrypt for password hashing, implement refresh tokens for better security, and add middleware to protect routes. Always validate tokens on every protected request and implement proper authorization checks to ensure users can only access their own resources.

Auth Middleware
import jwt from 'jsonwebtoken';

interface JWTPayload {
  userId: string;
  role: string;
}

export const authenticate = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const token = req.headers.authorization?.split(' ')[1];
    
    if (!token) {
      throw new AppError(401, 'No token provided');
    }
    
    const decoded = jwt.verify(
      token,
      process.env.JWT_SECRET!
    ) as JWTPayload;
    
    req.user = decoded;
    next();
  } catch (error) {
    next(new AppError(401, 'Invalid token'));
  }
};

// Role-based authorization
export const authorize = (...roles: string[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user || !roles.includes(req.user.role)) {
      return next(new AppError(403, 'Insufficient permissions'));
    }
    next();
  };
};

Rate Limiting and Security

Protect your API from abuse with rate limiting using libraries like express-rate-limit. Implement security headers with helmet, enable CORS properly, use express-mongo-sanitize to prevent NoSQL injection, and add request size limits. These measures protect against common attacks and ensure your API remains available under load.

Security Setup
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import mongoSanitize from 'express-mongo-sanitize';

// 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, please try again later'
});

// Apply security middleware
app.use(helmet()); // Security headers
app.use(mongoSanitize()); // Prevent NoSQL injection
app.use(express.json({ limit: '10mb' })); // Body size limit
app.use(limiter); // Rate limiting

Logging and Monitoring

Implement comprehensive logging with libraries like Winston or Pino. Log all errors, important events, and performance metrics. Structure your logs with consistent formats and appropriate log levels (error, warn, info, debug). In production, integrate with monitoring tools like Sentry or DataDog to track errors and performance in real-time.

Logger Setup
import winston from 'winston';

export const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ 
      filename: 'error.log', 
      level: 'error' 
    }),
    new winston.transports.File({ 
      filename: 'combined.log' 
    }),
  ],
});

// Console logging in development
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple(),
  }));
}

// Usage
logger.info('User created', { userId: user.id });
logger.error('Database connection failed', { error });

Database Best Practices

Use connection pooling for better performance, implement proper indexing for frequently queried fields, use transactions for operations that modify multiple records, and always handle connection errors gracefully. Consider using an ORM like Prisma or TypeORM for type-safe database queries and automatic migrations.

Conclusion

Building production-ready APIs requires attention to many details beyond basic CRUD operations. By following these best practices—proper project structure, validation, error handling, authentication, security measures, and monitoring—you create APIs that are secure, performant, and maintainable. Remember that API development is iterative; start with these foundations and continuously improve based on your application's specific needs and real-world usage patterns.

Node.jsExpressAPITypeScriptBackend
Share this article