Building Scalable REST APIs with Node.js and Express
Building robust and scalable REST APIs is a fundamental skill for full-stack developers. In this guide, we'll explore best practices for creating production-ready APIs using Node.js and Express.
Why Node.js for APIs?
Node.js excels at building APIs due to:
- Non-blocking I/O - Handles thousands of concurrent connections
- JavaScript Everywhere - Same language for frontend and backend
- Rich Ecosystem - NPM provides countless packages
- Fast Development - Rapid prototyping and iteration
- Scalability - Easy to scale horizontally
Project Structure Best Practices
Organize your API for maintainability:
src/
├── config/ # Configuration files
├── controllers/ # Request handlers
├── middleware/ # Custom middleware
├── models/ # Data models
├── routes/ # API routes
├── services/ # Business logic
├── utils/ # Helper functions
└── validators/ # Input validation
Essential Middleware Stack
Set up your Express app with critical middleware:
javascript
import express from "express";
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
const app = express();
// Security headers
app.use(helmet());
// CORS configuration
app.use(
cors({
origin: process.env.ALLOWED_ORIGINS?.split(","),
credentials: true,
})
);
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
});
app.use("/api/", limiter);
// Body parsing
app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true }));
RESTful Route Design
Follow REST conventions for intuitive APIs:
javascript
// Good REST design
GET /api/users # List all users
GET /api/users/:id # Get specific user
POST /api/users # Create new user
PUT /api/users/:id # Update user (full)
PATCH /api/users/:id # Update user (partial)
DELETE /api/users/:id # Delete user
// Nested resources
GET /api/users/:id/posts # Get user's posts
POST /api/users/:id/posts # Create post for user
Error Handling Strategy
Implement centralized error handling:
javascript
// Custom error class
class APIError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
// Global error handler middleware
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const message = err.isOperational ? err.message : "Internal server error";
if (process.env.NODE_ENV === "development") {
res.status(statusCode).json({
status: "error",
statusCode,
message,
stack: err.stack,
});
} else {
res.status(statusCode).json({
status: "error",
message,
});
}
});
Input Validation
Validate all incoming data:
javascript
import { z } from "zod";
const userSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18).max(120).optional(),
});
const validateUser = (req, res, next) => {
try {
userSchema.parse(req.body);
next();
} catch (error) {
res.status(400).json({
status: "error",
message: "Validation failed",
errors: error.errors,
});
}
};
app.post("/api/users", validateUser, createUser);
Database Integration
Use connection pooling for better performance:
javascript
import { Pool } from "pg";
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // connection pool size
idleTimeoutMillis: 30000,
});
// Example query with error handling
async function getUserById(id) {
const client = await pool.connect();
try {
const result = await client.query("SELECT * FROM users WHERE id = $1", [
id,
]);
return result.rows[0];
} finally {
client.release();
}
}
Authentication & Authorization
Implement JWT-based authentication:
javascript
import jwt from "jsonwebtoken";
// Generate token
function generateToken(user) {
return jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, {
expiresIn: "7d",
});
}
// Auth middleware
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({
status: "error",
message: "No token provided",
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({
status: "error",
message: "Invalid token",
});
}
}
Performance Optimization
Implement caching for frequently accessed data:
javascript
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
});
// Cache middleware
const cacheMiddleware = (duration) => async (req, res, next) => {
const key = cache:${req.originalUrl};
const cached = await redis.get(key);
if (cached) {
return res.json(JSON.parse(cached));
}
res.sendResponse = res.json;
res.json = (body) => {
redis.setex(key, duration, JSON.stringify(body));
res.sendResponse(body);
};
next();
};
// Use cache for expensive queries
app.get("/api/users", cacheMiddleware(300), getUsers);
Logging & Monitoring
Track API performance and errors:
javascript
import winston from "winston";
const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "combined.log" }),
],
});
// Request logging middleware
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();
});
API Documentation
Use Swagger/OpenAPI for documentation:
javascript
import swaggerUi from "swagger-ui-express";
import swaggerJsdoc from "swagger-jsdoc";
const options = {
definition: {
openapi: "3.0.0",
info: {
title: "My API",
version: "1.0.0",
description: "A well-documented API",
},
servers: [{ url: "http://localhost:3000" }],
},
apis: ["./src/routes/*.js"],
};
const specs = swaggerJsdoc(options);
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(specs));
Testing Your API
Write comprehensive tests:
javascript
import request from "supertest";
import { expect } from "chai";
import app from "../src/app";
describe("User API", () => {
it("should create a new user", async () => {
const res = await request(app).post("/api/users").send({
name: "John Doe",
email: "john@example.com",
});
expect(res.status).to.equal(201);
expect(res.body).to.have.property("id");
});
it("should return 400 for invalid email", async () => {
const res = await request(app).post("/api/users").send({
name: "John Doe",
email: "invalid-email",
});
expect(res.status).to.equal(400);
});
});
Deployment Checklist
Before going to production:
- ✅ Use environment variables for config
- ✅ Enable HTTPS/TLS
- ✅ Set up rate limiting
- ✅ Implement proper logging
- ✅ Add health check endpoints
- ✅ Configure CORS properly
- ✅ Use production database
- ✅ Set up monitoring (e.g., PM2, New Relic)
- ✅ Enable compression
- ✅ Document your API
Conclusion
Building scalable REST APIs requires careful planning and adherence to best practices. Focus on security, performance, and maintainability from day one. The patterns shown here will help you create APIs that can grow with your application.
Happy coding! 🚀