Node.js Express: Building Real APIs That Scale (2026)
Express is the most popular Node.js framework for good reason — it's simple, flexible, and battle-tested. Here's how to build production-ready APIs with it.
Project Structure That Scales
my-api/
├── src/
│ ├── index.js # Entry point
│ ├── app.js # Express app setup (no server start!)
│ ├── config/
│ │ └── index.js # Environment-based configuration
│ ├── routes/
│ │ ├── index.js # Route aggregation
│ │ ├── users.js # User routes
│ │ ├── posts.js # Post routes
│ │ └── health.js # Health check endpoint
│ ├── controllers/ # Route handlers (thin)
│ │ ├── userController.js
│ │ └── postController.js
│ ├── services/ # Business logic (no HTTP concerns)
│ │ ├── userService.js
│ │ └── postService.js
│ ├── middleware/
│ │ ├── auth.js # Authentication
│ │ ├── validate.js # Request validation
│ │ ├── errorHandler.js # Global error handler
│ │ └── rateLimit.js # Rate limiting
│ ├── models/ # Data models/schemas
│ │ ├── User.js
│ │ └── Post.js
│ ├── utils/
│ │ ├── logger.js # Winston/Pino logger
│ │ ├── response.js # Standardized response helpers
│ │ └── asyncHandler.js # Try-catch wrapper
│ └── db/
│ └── connection.js # Database connection pool
├── tests/
│ ├── unit/
│ └── integration/
├── .env.example # Template for environment variables
├── ecosystem.config.js # PM2 cluster config
└── package.json
The App Setup
// src/app.js — Pure Express app, no server listen!
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const routes = require('./routes');
function createApp() {
const app = express();
// === Security Middleware (order matters!) ===
app.use(helmet()); // Security headers FIRST
// CORS configuration:
const allowedOrigins = [
'https://myapp.com',
...(process.env.NODE_ENV !== 'production' ? ['http://localhost:3000', 'http://localhost:5173'] : []),
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
}));
// Body parsing:
app.use(express.json({ limit: '1mb' })); // JSON body (size limit!)
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// Request logging (skip in tests):
if (process.env.NODE_ENV !== 'test') {
app.use(morgan(process.env.LOG_FORMAT || 'combined', {
stream: require('./utils/logger').httpStream,
}));
}
// Request ID for tracing:
app.use((req, res, next) => {
req.id = req.headers['x-request-id'] || crypto.randomUUID();
res.setHeader('X-Request-ID', req.id);
next();
});
// Rate limiting:
app.use('/api/', rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
}));
// Trust proxy (behind Nginx/reverse proxy):
app.set('trust proxy', 1);
// === Routes ===
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString(), pid: process.pid });
});
app.use('/api/v1', routes);
// === Error Handling (MUST be last middleware) ===
app.use(require('./middleware/errorHandler'));
// 404 handler (no route matched):
app.use((req, res) => {
res.status(404).json({
error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.path} not found` }
});
});
return app;
}
module.exports = createApp;
Route Handlers & Controllers
// src/utils/asyncHandler.js — Eliminates try/catch boilerplate
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// src/middleware/auth.js — JWT Authentication
const jwt = require('jsonwebtoken');
function auth(options = {}) {
return (req, res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Missing token' } });
}
const token = header.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // Attach user to request
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: { code: 'TOKEN_EXPIRED', message: 'Token expired' } });
}
return res.status(401).json({ error: { code: 'INVALID_TOKEN', message: 'Invalid token' } });
}
};
}
// Role-based access control:
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: { code: 'FORBIDDEN', message: 'Insufficient permissions' } });
}
next();
};
}
// src/controllers/userController.js
const userService = require('../services/userService');
const asyncHandler = require('../utils/asyncHandler');
const { validate } = require('../middleware/validate');
// Validation schemas (using Joi or Zod):
const createUserSchema = {
body: {
email: 'required|email',
name: 'required|string|min:2|max:100',
password: 'required|string|min:8',
},
};
module.exports = {
listUsers: asyncHandler(async (req, res) => {
const { page = 1, limit = 20, sort = '-createdAt' } = req.query;
const result = await userService.findAll({ page: parseInt(limit), page: parseInt(page), sort });
res.json({ data: result.items, meta: result.pagination });
}),
getUser: asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: { code: 'USER_NOT_FOUND', message: 'User not found' } });
}
res.json({ data: user });
}),
createUser: asyncHandler(async (req, res) => {
const user = await userService.create(req.body); // Already validated by middleware
res.status(201).json({ data: user });
}),
updateUser: asyncHandler(async (req, res) => {
const user = await userService.update(req.params.id, req.body);
if (!user) {
return res.status(404).json({ error: { code: 'USER_NOT_FOUND', message: 'User not found' } });
}
res.json({ data: user });
}),
deleteUser: asyncHandler(async (req, res) => {
const deleted = await userService.remove(req.params.id);
if (!deleted) {
return res.status(404).json({ error: { code: 'USER_NOT_FOUND', message: 'User not found' } });
}
res.status(204).send();
}),
};
Service Layer (Business Logic)
// src/services/userService.js — No HTTP knowledge here!
const db = require('../db/connection');
const bcrypt = require('bcrypt');
const { AppError } = require('../utils/errors');
class UserService {
async findAll({ page = 1, limit = 20, sort = '-createdAt' }) {
const offset = (page - 1) * limit;
const [items, count] = await Promise.all([
db('users')
.select('id', 'name', 'email', 'role', 'created_at')
.orderBy(sort.replace('-', ''), sort.startsWith('-') ? 'desc' : 'asc')
.limit(limit)
.offset(offset),
db('users').count('* as total').first(),
]);
return {
items,
pagination: {
page,
per_page: limit,
total_count: count.total,
total_pages: Math.ceil(count.total / limit),
has_next: page * limit < count.total,
has_prev: page > 1,
},
};
}
async findById(id) {
const user = await db('users').where({ id }).first().select(
'id', 'name', 'email', 'role', 'created_at'
);
return user || null;
}
async create(data) {
// Check if email already exists
const existing = await db('users').where({ email: data.email }).first();
if (existing) {
throw new AppError('Email already exists', {
code: 'DUPLICATE_EMAIL',
statusCode: 409
});
}
const hashedPassword = await bcrypt.hash(data.password, 12);
const [user] = await db('users').insert({
name: data.name,
email: data.email.toLowerCase(),
password_hash: hashedPassword,
role: data.role || 'user',
}).returning(['id', 'name', 'email', 'role', 'created_at']);
return user;
}
async update(id, data) {
// Don't allow updating these fields through this method:
const allowedFields = ['name', 'email'];
const updates = {};
for (const field of allowedFields) {
if (data[field] !== undefined) {
updates[field] = field === 'email' ? data[field].toLowerCase() : data[field];
}
}
if (Object.keys(updates).length === 0) {
throw new AppError('No valid fields to update', { code: 'NO_UPDATES', statusCode: 400 });
}
const [user] = await db('users').where({ id }).update(updates)
.returning(['id', 'name', 'email', 'role', 'updated_at']);
return user || null;
}
async remove(id) {
const deleted = await db('users').where({ id }).del();
return deleted > 0;
}
}
module.exports = new UserService();
Entry Point & Server Start
// src/index.js — Server creation and startup
const createApp = require('./app');
function normalizePort(val) {
const port = parseInt(val, 10);
if (isNaN(port)) return val; // Named pipe
if (port >= 0) return port;
return false;
}
const PORT = normalizePort(process.env.PORT || '3000');
const app = createApp();
const server = app.listen(PORT, () => {
console.log(`🚀 API running on port ${PORT} (${process.env.NODE_ENV})`);
});
// Graceful shutdown:
function shutdown(signal) {
console.log(`\n${signal} received. Shutting down gracefully...`);
// Stop accepting new connections
server.close(() => {
console.log('HTTP server closed.');
process.exit(0);
});
// Force exit after timeout
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}.unref(), 10000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Unhandled rejection protection:
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
if (process.env.NODE_ENV === 'development') process.exit(1);
});
module.exports = { app, server };
What's your favorite Express pattern? What framework do you prefer over Express and why?
Follow @armorbreak for more practical developer guides.
Top comments (0)