Node.js Express: Building Real APIs That Scale (2026)
Express is simple by design — but building production-ready APIs requires more than app.get(). Here's the complete picture.
Project Structure That Scales
src/
├── app.js # Express app setup (no server start!)
├── server.js # Server entry point (imports app, starts listening)
├── config/
│ ├── index.js # Config loader (env-based)
│ └── database.js # DB connection config
├── routes/
│ ├── index.js # Route aggregator
│ ├── users.route.js # User endpoints
│ └── posts.route.js # Post endpoints
├── controllers/
│ ├── user.controller.js
│ └── post.controller.js
├── middleware/
│ ├── auth.js # Authentication
│ ├── validate.js # Request validation
│ ├── rateLimit.js # Rate limiting
│ ├── errorHandler.js # Global error handler
│ └── logger.js # Request logging
├── services/
│ ├── user.service.js # Business logic
│ └── post.service.js
├── models/
│ ├── user.model.js # Data access layer
│ └── post.model.js
├── utils/
│ ├── response.js # Standardized API responses
│ ├── errors.js # Custom error classes
│ └── asyncHandler.js # Try-catch wrapper
└── validators/
├── user.validator.js
└── post.validator.js
The Express App Setup
// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
const routes = require('./routes');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
const { requestLogger } = require('./middleware/logger');
const rateLimiter = require('./middleware/rateLimit');
function createApp() {
const app = express();
// Security headers
app.use(helmet());
// CORS configuration
app.use(cors({
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// Compression (reduce payload size ~70%)
app.use(compression({ threshold: 1024 }));
// Body parsing with size limits
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// Request logging
if (process.env.NODE_ENV !== 'test') {
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
}
app.use(requestLogger);
// Global rate limiting
app.use('/api/', rateLimiter);
// Health check endpoint (no auth required)
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV,
version: process.env.npm_package_version || '1.0.0',
});
});
// API routes
app.use('/api/v1', routes);
// 404 handler (must be after all routes)
app.use(notFoundHandler);
// Global error handler (must be LAST middleware)
app.use(errorHandler);
return app;
}
module.exports = createApp;
// src/server.js
const createApp = require('./app');
const PORT = process.env.PORT || 3000;
const app = createApp();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT} in ${process.env.NODE_ENV} mode`);
});
module.exports = app; // For testing without starting server
Middleware Patterns
// src/middleware/auth.js — JWT Authentication
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid authorization header' });
}
const token = header.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Attach user to request for downstream use
req.user = {
id: decoded.sub,
email: decoded.email,
role: decoded.role,
};
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Role-based authorization (composable!)
function authorize(...roles) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
module.exports = { authenticate, authorize };
// Usage:
// router.get('/admin', authenticate, authorize('admin'), adminDashboard);
// router.get('/profile', authenticate, getProfile); // Any authenticated user
// src/middleware/validate.js — Schema Validation
const { validationResult, body, param, query } = require('express-validator');
function validate(rules) {
return async (req, res, next) => {
await Promise.all(rules.map(rule => rule.run(req)));
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
error: 'Validation failed',
details: errors.array().map(err => ({
field: err.path,
message: err.msg,
value: err.value,
})),
});
}
next();
};
}
// Pre-built validators for common cases:
const validators = {
createUser: [
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
body('name').trim().notEmpty().isLength({ max: 100 }),
],
getUserById: [
param('id').isUUID().withMessage('Invalid user ID format'),
],
listPosts: [
query('page').optional().isInt({ min: 1 }).toInt(),
query('limit').optional().isInt({ min: 1, max: 100 }).toInt(),
query('sort').optional().isIn(['createdAt', 'title', 'views']),
query('order').optional().isIn(['asc', 'desc']),
],
};
module.exports = { validate, validators };
Standardized Responses
// src/utils/response.js
class ApiResponse {
static success(data, meta = {}) {
return {
success: true,
data,
...Object.keys(meta).length && { meta },
timestamp: new Date().toISOString(),
};
}
static paginated(items, page, limit, total) {
return this.success(items, {
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
});
}
static created(data) {
return { success: true, data, timestamp: new Date().toISOString() };
}
}
// src/utils/errors.js
class AppError extends Error {
constructor(message, statusCode = 500, code = 'ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // Distinguish from programming errors
Error.captureStackTrace(this, this.constructor);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') { super(`${resource} not found`, 404, 'NOT_FOUND'); }
}
class ValidationError extends AppError {
constructor(details) { super('Validation failed', 422, 'VALIDATION_ERROR', details); }
}
class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') { super(message, 401, 'UNAUTHORIZED'); }
}
class ForbiddenError extends AppError {
constructor(message = 'Forbidden') { super(message, 403, 'FORBIDDEN'); }
}
// src/middleware/errorHandler.js
function errorHandler(err, req, res, _next) {
const incidentId = crypto.randomUUID();
// Log every error
console.error(`[${incidentId}]`, {
message: err.message,
stack: err.stack,
code: err.code,
path: req.path,
method: req.method,
userId: req.user?.id,
});
const isProduction = process.env.NODE_ENV === 'production';
const statusCode = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
res.status(statusCode).json({
success: false,
error: {
code,
message: isProduction && statusCode === 500
? 'An internal error occurred'
: err.message,
...(err.details && { details: err.details }),
...(isProduction && { incidentId }),
},
timestamp: new Date().toISOString(),
});
}
function notFoundHandler(req, res) {
res.status(404).json({
success: false,
error: { code: 'NOT_FOUND', message: `${req.method} ${req.path} not found` },
timestamp: new Date().toISOString(),
});
}
Route & Controller Pattern
// src/routes/users.route.js
const router = require('express').Router();
const { asyncHandler } = require('../utils/asyncHandler');
const { authenticate, authorize } = require('../middleware/auth');
const { validate, validators } = require('../middleware/validate');
const userController = require('../controllers/user.controller');
router.post('/', validate(validators.createUser), asyncHandler(userController.create));
router.get('/', asyncHandler(userController.list));
router.get('/:id', validate(validators.getUserById), asyncHandler(userController.getById));
router.put('/:id', authenticate, validate(validators.updateUser), asyncHandler(userController.update));
router.delete('/:id', authenticate, authorize('admin'), asyncHandler(userController.delete));
module.exports = router;
// src/controllers/user.controller.js
const userService = require('../services/user.service');
const { ApiResponse } = require('../utils/response');
const { NotFoundError } = require('../utils/errors');
exports.create = async (req, res) => {
const user = await userService.create(req.body);
res.status(201).json(ApiResponse.created(user));
};
exports.list = async (req, res) => {
const { page = 1, limit = 20, sort = 'createdAt', order = 'desc' } = req.query;
const result = await userService.list({ page, limit, sort, order });
res.json(ApiResponse.paginated(result.items, page, limit, result.total));
};
exports.getById = async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError('User');
res.json(ApiResponse.success(user));
};
// src/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
Performance Checklist
// ✅ Enable gzip/brotli compression → done via compression()
// ✅ Response caching (for GET endpoints):
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 300 }); // 5-minute TTL
function cacheMiddleware(duration = 300) {
return (req, res, next) => {
if (req.method !== 'GET') return next();
const key = req.originalUrl;
const cached = cache.get(key);
if (cached) return res.json(cached);
res.originalJson = res.json.bind(res);
res.json = (body) => { cache.set(key, body, duration); return res.originalJson(body); };
next();
};
}
// Usage: router.get('/posts', cacheMiddleware(60), listPosts)
// ✅ Connection pooling for DB:
const { Pool } = require('pg');
const pool = new Pool({ max: 20, idleTimeoutMillis: 30000 });
// ✅ Rate limiting per route:
const expressRateLimit = require('express-rate-limit');
const strictLimiter = expressRateLimit({ windowMs: 15 * 60 * 1000, max: 50 });
const authLimiter = expressRateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
// ✅ Use streams for large responses:
app.get('/api/export', (req, res) => {
res.setHeader('Content-Type', 'text/csv');
bigDataStream.pipe(res); // Memory-efficient!
});
What's your favorite Express pattern? What would you add to this guide?
Follow @armorbreak for more practical developer guides.
Top comments (0)