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:
class="keyword">import express class="keyword">from class="keyword">class="string">"express";
class="keyword">import helmet class="keyword">from class="keyword">class="string">"helmet";
class="keyword">import cors class="keyword">from class="keyword">class="string">"cors";
class="keyword">import rateLimit class="keyword">from class="keyword">class="string">"express-rate-limit";
class="keyword">class="keyword">const app = express();
class="keyword">class="comment">// Security headers
app.use(helmet());
class="keyword">class="comment">// CORS configuration
app.use(
cors({
origin: process.env.ALLOWED_ORIGINS?.split(class="keyword">class="string">","),
credentials: true,
})
);
class="keyword">class="comment">// Rate limiting
class="keyword">class="keyword">const limiter = rateLimit({
windowMs: 15 * 60 * 1000, class="keyword">class="comment">// 15 minutes
max: 100, class="keyword">class="comment">// limit each IP to 100 requests per windowMs
});
app.use(class="keyword">class="string">"/api/", limiter);
class="keyword">class="comment">// Body parsing
app.use(express.json({ limit: class="keyword">class="string">"10mb" }));
app.use(express.urlencoded({ extended: true }));
RESTful Route Design
Follow REST conventions for intuitive APIs:
class="keyword">class="comment">// Good REST design
GET /api/users # List all users
GET /api/users/:id # Get specific user
POST /api/users # Create class="keyword">new user
PUT /api/users/:id # Update user(full)
PATCH /api/users/:id # Update user(partial)
class="keyword">DELETE /api/users/:id # Delete user
class="keyword">class="comment">// Nested resources
GET /api/users/:id/posts # Get user's posts
POST /api/users/:id/posts # Create post class="keyword">class="keyword">for user
Error Handling Strategy
Implement centralized error handling:
class="keyword">class="comment">// Custom error class="keyword">class
class="keyword">class APIError class="keyword">extends Error {
constructor(message, statusCode) {
class="keyword">super(message);
class="keyword">this.statusCode = statusCode;
class="keyword">this.isOperational = true;
Error.captureStackTrace(class="keyword">this, class="keyword">this.constructor);
}
}
class="keyword">class="comment">// Global error handler middleware
app.use((err, req, res, next) => {
class="keyword">class="keyword">const statusCode = err.statusCode || 500;
class="keyword">class="keyword">const message = err.isOperational ? err.message : class="keyword">class="string">"Internal server error";
class="keyword">class="keyword">if (process.env.NODE_ENV === class="keyword">class="string">"development") {
res.status(statusCode).json({
status: class="keyword">class="string">"error",
statusCode,
message,
stack: err.stack,
});
} class="keyword">class="keyword">else {
res.status(statusCode).json({
status: class="keyword">class="string">"error",
message,
});
}
});
Input Validation
Validate all incoming data:
class="keyword">import { z } class="keyword">from class="keyword">class="string">"zod";
class="keyword">class="keyword">const userSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18).max(120).optional(),
});
class="keyword">class="keyword">const validateUser = (req, res, next) => {
class="keyword">try {
userSchema.parse(req.body);
next();
} class="keyword">catch (error) {
res.status(400).json({
status: class="keyword">class="string">"error",
message: class="keyword">class="string">"Validation failed",
errors: error.errors,
});
}
};
app.post(class="keyword">class="string">"/api/users", validateUser, createUser);
Database Integration
Use connection pooling for better performance:
class="keyword">import { Pool } class="keyword">from class="keyword">class="string">"pg";
class="keyword">class="keyword">const pool = class="keyword">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, class="keyword">class="comment">// connection pool size
idleTimeoutMillis: 30000,
});
class="keyword">class="comment">// Example query with error handling
class="keyword">class="keyword">async class="keyword">class="keyword">function getUserById(id) {
class="keyword">class="keyword">const client = class="keyword">class="keyword">await pool.connect();
class="keyword">try {
class="keyword">class="keyword">const result = class="keyword">class="keyword">await client.query(class="keyword">class="string">"class="keyword">SELECT * class="keyword">FROM users class="keyword">WHERE id = $1", [
id,
]);
class="keyword">class="keyword">return result.rows[0];
} class="keyword">finally {
client.release();
}
}
Authentication & Authorization
Implement JWT-based authentication:
class="keyword">import jwt class="keyword">from class="keyword">class="string">"jsonwebtoken";
class="keyword">class="comment">// Generate token
class="keyword">class="keyword">function generateToken(user) {
class="keyword">class="keyword">return jwt.sign({ id: user.id, email: user.email }, process.env.JWT_SECRET, {
expiresIn: class="keyword">class="string">"7d",
});
}
class="keyword">class="comment">// Auth middleware
class="keyword">class="keyword">function authenticate(req, res, next) {
class="keyword">class="keyword">const token = req.headers.authorization?.split(class="keyword">class="string">" ")[1];
class="keyword">class="keyword">if (!token) {
class="keyword">class="keyword">return res.status(401).json({
status: class="keyword">class="string">"error",
message: class="keyword">class="string">"No token provided",
});
}
class="keyword">try {
class="keyword">class="keyword">const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} class="keyword">catch (error) {
res.status(401).json({
status: class="keyword">class="string">"error",
message: class="keyword">class="string">"Invalid token",
});
}
}
Performance Optimization
Implement caching for frequently accessed data:
class="keyword">import Redis class="keyword">from class="keyword">class="string">"ioredis";
class="keyword">class="keyword">const redis = class="keyword">new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
});
class="keyword">class="comment">// Cache middleware
class="keyword">class="keyword">const cacheMiddleware = (duration) => class="keyword">class="keyword">async (req, res, next) => {
class="keyword">class="keyword">const key = class="keyword">class="string">cache:${req.originalUrl};
class="keyword">class="keyword">const cached = class="keyword">class="keyword">await redis.get(key);
class="keyword">class="keyword">if (cached) {
class="keyword">class="keyword">return res.json(JSON.parse(cached));
}
res.sendResponse = res.json;
res.json = (body) => {
redis.setex(key, duration, JSON.stringify(body));
res.sendResponse(body);
};
next();
};
class="keyword">class="comment">// Use cache class="keyword">class="keyword">for expensive queries
app.get(class="keyword">class="string">"/api/users", cacheMiddleware(300), getUsers);
Logging & Monitoring
Track API performance and errors:
class="keyword">import winston class="keyword">from class="keyword">class="string">"winston";
class="keyword">class="keyword">const logger = winston.createLogger({
level: class="keyword">class="string">"info",
format: winston.format.json(),
transports: [
class="keyword">new winston.transports.File({ filename: class="keyword">class="string">"error.log", level: class="keyword">class="string">"error" }),
class="keyword">new winston.transports.File({ filename: class="keyword">class="string">"combined.log" }),
],
});
class="keyword">class="comment">// Request logging middleware
app.use((req, res, next) => {
class="keyword">class="keyword">const start = Date.now();
res.on(class="keyword">class="string">"finish", () => {
logger.info({
method: req.method,
url: req.url,
status: res.statusCode,
duration: Date.now() - start,
});
});
next();
});
API Documentation
Use Swagger/OpenAPI for documentation:
class="keyword">import swaggerUi class="keyword">from class="keyword">class="string">"swagger-ui-express";
class="keyword">import swaggerJsdoc class="keyword">from class="keyword">class="string">"swagger-jsdoc";
class="keyword">class="keyword">const options = {
definition: {
openapi: class="keyword">class="string">"3.0.0",
info: {
title: class="keyword">class="string">"My API",
version: class="keyword">class="string">"1.0.0",
description: class="keyword">class="string">"A well-documented API",
},
servers: [{ url: class="keyword">class="string">"http:class="keyword">class="comment">//localhost:3000" }],
},
apis: [class="keyword">class="string">"./src/routes/*.js"],
};
class="keyword">class="keyword">const specs = swaggerJsdoc(options);
app.use(class="keyword">class="string">"/api-docs", swaggerUi.serve, swaggerUi.setup(specs));
Testing Your API
Write comprehensive tests:
class="keyword">import request class="keyword">from class="keyword">class="string">"supertest";
class="keyword">import { expect } class="keyword">from class="keyword">class="string">"chai";
class="keyword">import app class="keyword">from class="keyword">class="string">"../src/app";
describe(class="keyword">class="string">"User API", () => {
it(class="keyword">class="string">"should create a class="keyword">new user", class="keyword">class="keyword">async () => {
class="keyword">class="keyword">const res = class="keyword">class="keyword">await request(app).post(class="keyword">class="string">"/api/users").send({
name: class="keyword">class="string">"John Doe",
email: class="keyword">class="string">"john@example.com",
});
expect(res.status).to.equal(201);
expect(res.body).to.have.property(class="keyword">class="string">"id");
});
it(class="keyword">class="string">"should class="keyword">class="keyword">return 400 class="keyword">class="keyword">for invalid email", class="keyword">class="keyword">async () => {
class="keyword">class="keyword">const res = class="keyword">class="keyword">await request(app).post(class="keyword">class="string">"/api/users").send({
name: class="keyword">class="string">"John Doe",
email: class="keyword">class="string">"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! 🚀