Building a Production-Ready REST API with Express.js
Building APIs that work in development is easy. Building APIs that are production-ready requires careful attention to error handling, security, performance, and maintainability.
Project Structure
A well-organized project structure is crucial:
textsrc/ ├── config/ │ ├── database.js │ ├── logger.js │ └── index.js ├── controllers/ │ └── userController.js ├── middleware/ │ ├── auth.js │ ├── errorHandler.js │ ├── rateLimiter.js │ └── validator.js ├── models/ │ └── User.js ├── routes/ │ ├── index.js │ └── userRoutes.js ├── services/ │ └── userService.js ├── utils/ │ ├── ApiError.js │ └── asyncHandler.js └── app.js
Core Setup
Application Entry Point
javascript// src/app.js const express = require('express'); const helmet = require('helmet'); const cors = require('cors'); const compression = require('compression'); const { errorHandler } = require('./middleware/errorHandler'); const routes = require('./routes'); const logger = require('./config/logger'); const app = express(); // Security middleware app.use(helmet()); app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*', credentials: true })); // Body parsing app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); // Compression app.use(compression()); // Request logging app.use((req, res, next) => { const start = Date.now(); res.on('finish', () => { logger.info({ method: req.method, url: req.url, status: res.statusCode, duration: Date.now() - start }); }); next(); }); // Routes app.use('/api/v1', routes); // Health check app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString() }); }); // Error handling (must be last) app.use(errorHandler); module.exports = app;
Error Handling
Custom API Error Class
javascript// src/utils/ApiError.js class ApiError extends Error { constructor(statusCode, message, isOperational = true, stack = '') { super(message); this.statusCode = statusCode; this.isOperational = isOperational; if (stack) { this.stack = stack; } else { Error.captureStackTrace(this, this.constructor); } } static badRequest(message) { return new ApiError(400, message); } static unauthorized(message = 'Unauthorized') { return new ApiError(401, message); } static forbidden(message = 'Forbidden') { return new ApiError(403, message); } static notFound(message = 'Resource not found') { return new ApiError(404, message); } static internal(message = 'Internal server error') { return new ApiError(500, message, false); } } module.exports = ApiError;
Error Handler Middleware
javascript// src/middleware/errorHandler.js const logger = require('../config/logger'); const ApiError = require('../utils/ApiError'); const errorHandler = (err, req, res, next) => { let error = err; // Convert non-ApiError to ApiError if (!(error instanceof ApiError)) { const statusCode = error.statusCode || 500; const message = error.message || 'Internal Server Error'; error = new ApiError(statusCode, message, false, err.stack); } // Log error logger.error({ message: error.message, stack: error.stack, url: req.url, method: req.method, body: req.body }); const response = { success: false, message: error.message, ...(process.env.NODE_ENV === 'development' && { stack: error.stack }) }; res.status(error.statusCode).json(response); }; module.exports = { errorHandler };
Input Validation
Using Joi for Validation
javascript// src/middleware/validator.js const Joi = require('joi'); const ApiError = require('../utils/ApiError'); const validate = (schema) => (req, res, next) => { const { error } = schema.validate(req.body, { abortEarly: false, stripUnknown: true }); if (error) { const errors = error.details.map(d => d.message); return next(ApiError.badRequest(errors.join(', '))); } next(); }; // Validation schemas const schemas = { createUser: Joi.object({ email: Joi.string().email().required(), password: Joi.string().min(8).required(), name: Joi.string().min(2).max(100).required() }), updateUser: Joi.object({ email: Joi.string().email(), name: Joi.string().min(2).max(100) }) }; module.exports = { validate, schemas };
Authentication
JWT Authentication Middleware
javascript// src/middleware/auth.js const jwt = require('jsonwebtoken'); const ApiError = require('../utils/ApiError'); const authenticate = async (req, res, next) => { try { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { throw ApiError.unauthorized('No token provided'); } const token = authHeader.substring(7); const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = decoded; next(); } catch (error) { if (error.name === 'JsonWebTokenError') { next(ApiError.unauthorized('Invalid token')); } else if (error.name === 'TokenExpiredError') { next(ApiError.unauthorized('Token expired')); } else { next(error); } } }; const authorize = (...roles) => (req, res, next) => { if (!roles.includes(req.user.role)) { return next(ApiError.forbidden('Insufficient permissions')); } next(); }; module.exports = { authenticate, authorize };
Rate Limiting
javascript// src/middleware/rateLimiter.js const rateLimit = require('express-rate-limit'); const RedisStore = require('rate-limit-redis'); const redis = require('../config/redis'); const createRateLimiter = (options = {}) => { return rateLimit({ store: new RedisStore({ client: redis, prefix: 'rl:' }), windowMs: options.windowMs || 15 * 60 * 1000, // 15 minutes max: options.max || 100, message: { success: false, message: 'Too many requests, please try again later' }, standardHeaders: true, legacyHeaders: false }); }; module.exports = { createRateLimiter };
Controller Pattern
javascript// src/controllers/userController.js const userService = require('../services/userService'); const asyncHandler = require('../utils/asyncHandler'); const getUsers = asyncHandler(async (req, res) => { const { page = 1, limit = 10 } = req.query; const users = await userService.findAll({ page, limit }); res.json({ success: true, data: users }); }); const getUserById = asyncHandler(async (req, res) => { const user = await userService.findById(req.params.id); res.json({ success: true, data: user }); }); const createUser = asyncHandler(async (req, res) => { const user = await userService.create(req.body); res.status(201).json({ success: true, data: user }); }); module.exports = { getUsers, getUserById, createUser };
Conclusion
A production-ready API requires attention to many details: proper error handling, input validation, authentication, rate limiting, and logging. This structure provides a solid foundation that scales well and is maintainable over time.