Node.js Express: Building Real APIs That Scale (2026)
Express is the most popular Node.js framework for good reason. 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 # Config loader (env-based)
│ │ └── database.js # DB config
│ ├── routes/
│ │ ├── index.js # Route aggregator
│ │ ├── users.js # User routes
│ │ ├── posts.js # Post routes
│ │ └── health.js # Health check route
│ ├── controllers/ # Route handlers (business logic)
│ │ ├── userController.js
│ │ └── postController.js
│ ├── middleware/
│ │ ├── auth.js # Authentication
│ │ ├── validate.js # Request validation
│ │ ├── errorHandler.js # Global error handler
│ │ └── rateLimit.js # Rate limiting
│ ├── models/
│ │ ├── User.js # Data model/schema
│ │ └── Post.js
│ ├── services/
│ │ ├── userService.js # Business logic (separated from HTTP!)
│ │ └── postService.js
│ ├── utils/
│ │ ├── response.js # Standardized response helper
│ │ ├── logger.js # Winston/Pino logger
│ │ └── asyncHandler.js # Try-catch wrapper
│ └── db/
│ ├── connection.js # DB connection pool
│ └── queries.js # SQL queries
├── tests/
│ ├── unit/
│ └── integration/
├── .env.example
├── package.json
├── tsconfig.json # If using TypeScript
└── Dockerfile
App Setup (app.js — No Server Start!)
// app.js — Configure Express, DON'T call app.listen()
// This makes it testable and reusable
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
// Create app instance
const app = express();
// === Security Middleware (order matters!) ===
app.use(helmet()); // Security headers
app.use(cors({
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// === Body Parsing ===
app.use(express.json({ limit: '1mb' })); // JSON body
app.use(express.urlencoded({ extended: true })); // Form data
// === 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();
});
// === Logging ===
const morgan = require('morgan');
app.use(morgan('combined', {
immediate: false,
skip: () => process.env.NODE_ENV === 'test',
}));
// === Rate Limiting ===
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests' },
});
app.use('/api/', limiter);
// Stricter auth limits:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many login attempts' },
});
// === Routes ===
app.use('/api/health', require('./routes/health'));
app.use('/api/users', require('./routes/users'));
app.use('/api/posts', require('./routes/posts'));
// === Error Handling ===
// 404 handler (must be AFTER all routes)
app.use((req, res) => {
res.status(404).json({
error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.path} not found` }
});
});
// Global error handler (must have 4 params!)
app.use((err, req, res, _next) => {
const status = err.status || 500;
// Don't expose stack traces in production
const message = process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: err.message;
logger.error(`[Error] ${req.id}`, { error: err.message, stack: err.stack });
res.status(status).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message,
...(process.env.NODE_ENV !== 'production' && { details: err.details }),
requestId: req.id,
}
});
});
module.exports = app;
Async Handler Wrapper
// utils/asyncHandler.js — Eliminates try/catch boilerplate in every route!
// ❌ Without wrapper (repetitive):
app.get('/users', async (req, res, next) => {
try {
const users = await userService.getAll();
res.json({ data: users });
} catch (err) {
next(err); // Must pass to error handler
}
});
// ✅ With wrapper:
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// Usage:
app.get('/users', asyncHandler(async (req, res) => {
const users = await userService.getAll();
res.json({ data: users }));
});
// Even better — with typed errors:
class AppError extends Error {
constructor(message, statusCode = 500, code = 'ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
}
}
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
// AppError instances are handled by global error handler automatically!
// Other errors (TypeError, etc.) become 500 Internal Server Error
// Usage in controllers:
const getUser = asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) throw new AppError('User not found', 404, 'USER_NOT_FOUND');
res.json({ data: user });
});
Route Organization
// routes/users.js
const express = require('express');
const router = express.Router();
const { authenticate, authorize } = require('../middleware/auth');
const { validate } = require('../middleware/validate');
const { createUserSchema, updateUserSchema } = require('../schemas/userSchemas');
const { listUsers, getUser, createUser, updateUser, deleteUser } = require('../controllers/userController');
// All routes prefixed with /api/users (where mounted in app.js)
// Public routes
router.post('/', validate(createUserSchema), createUser);
// Protected routes (require authentication)
router.use(authenticate); // All routes below need auth
router.get('/', listUsers);
router.get('/me', getProfile); // Current user's profile
router.get('/:id', getUser);
// Admin-only routes
router.patch('/:id', authorize('admin'), validate(updateUserSchema), updateUser);
router.delete('/:id', authorize('admin'), deleteUser);
module.exports = router;
// Controller (controllers/userController.js):
const userService = require('../services/userService');
exports.listUsers = asyncHandler(async (req, res) => {
const { page = 1, limit = 20, role, sort } = req.query;
const result = await userService.list({
page: Math.max(1, parseInt(page)),
limit: Math.min(100, parseInt(limit)),
role,
sort,
});
res.json({
data: result.items,
meta: { page: result.page, limit: result.limit, total: result.total },
});
});
exports.getUser = asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) throw new AppError('User not found', 404, 'USER_NOT_FOUND');
res.json({ data: user });
});
Authentication Middleware
// middleware/auth.js
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({
error: { code: 'UNAUTHORIZED', message: 'Missing or invalid authorization header' }
});
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Prevent algorithm confusion attack
issuer: 'my-app',
});
req.user = payload; // Attach user to request
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: { code: 'TOKEN_EXPIRED', message: 'Token has expired' }
});
}
return res.status(401).json({
error: { code: 'INVALID_TOKEN', message: 'Invalid token' }
});
}
}
// Role-based authorization:
function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }
});
}
next();
};
}
// Optional authentication (sets user if token present, but doesn't require it):
function optionalAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) return next();
try {
req.user = jwt.verify(header.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
} catch (_) { /* Ignore invalid tokens */ }
next();
}
Database Integration
// db/connection.js — Connection pool with proper lifecycle
const { Pool } = require('pg'); // PostgreSQL example
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // Max connections
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 2000, // Fail fast if can't connect
});
// Graceful shutdown
process.on('SIGTERM', () => pool.end().then(() => process.exit(0)));
process.on('SIGINT', () => pool.end().then(() => process.exit(0)));
// Query helper with logging:
async function query(text, params) {
const start = Date.now();
const res = await pool.query(text, params);
const duration = Date.now() - start;
if (duration > 1000) {
logger.warn(`Slow query (${duration}ms): ${text.slice(0, 100)}`);
}
return res;
}
module.exports = { pool, query };
// Service layer (services/userService.js):
const { query } = require('../db/connection');
exports.list = async ({ page, limit, role, sort }) => {
let sql = 'SELECT id, name, email, role, created_at FROM users WHERE 1=1';
const params = [];
let paramIndex = 1;
if (role) {
sql += ` AND role = $${paramIndex++}`;
params.push(role);
}
const countSql = `SELECT COUNT(*) FROM users WHERE 1=1` +
(role ? ` AND role = $1` : '');
const orderClause = sort ? ` ORDER BY ${sort.replace(/[^a-z_]/gi, '')}` : '';
sql += orderClause + ` LIMIT $${paramIndex++} OFFSET $${paramIndex++}`;
params.push(limit, (page - 1) * limit);
const [countResult, itemsResult] = await Promise.all([
query(countSql, role ? [role] : []),
query(sql, params),
]);
return {
items: itemsResult.rows,
total: parseInt(countResult.rows[0].count),
page,
limit,
};
};
Entry Point & Server Lifecycle
// index.js — ONLY handles server startup
const app = require('./app');
const config = require('./config');
let server;
function startServer() {
server = app.listen(config.port, () => {
console.log(`🚀 API running on port ${config.port} (${config.nodeEnv})`);
});
server.setTimeout(120000); // 2 min timeout for long requests
server.keepAliveTimeout = 65000; // Slightly less than load balancer timeout
// Handle graceful shutdown
const shutdown = (signal) => {
console.log(`${signal} received. Shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed.');
process.exit(0);
});
// Force exit after 10 seconds
setTimeout(() => process.exit(1), 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
startServer();
// For testing: module.exports = { app, startServer };
What's your favorite Express pattern? What do you wish you knew when you started building APIs?
Follow @armorbreak for more practical developer guides.
Top comments (0)