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.

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.
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.
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.
// 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.
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.
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 limitingLogging 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.
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.