Node.js Express: Building Real APIs That Scale (2026)
Express is still the most popular Node.js framework. Here's how to use it right — from hello world to production-ready APIs.
Quick Setup
mkdir my-api && cd my-api
npm init -y
npm install express
npm install --save-dev typescript tsx @types/express @types/node
# package.json
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "tsc"
}
}
The Minimum Viable API
import express from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware (order matters!)
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Form data
// Routes
app.get('/', (req, res) => {
res.json({ message: 'API is running', version: '1.0.0' });
});
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime(), memory: process.memoryUsage() });
});
// 404 handler (must be LAST)
app.use((req, res) => {
res.status(404).json({ error: { code: 'NOT_FOUND', message: `Route ${req.method} ${req.path} not found` } });
});
// Error handler (must have 4 parameters!)
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(`[ERROR] ${err.message}`);
res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: 'An internal error occurred' } });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Route Organization
src/
routes/
users.ts # /api/users endpoints
auth.ts # /api/auth endpoints
health.ts # /api/health endpoints
middleware/
auth.ts # Authentication middleware
validate.ts # Input validation
errorHandler.ts # Global error handler
rateLimiter.ts # Rate limiting
controllers/
userController.ts # Business logic
services/
userService.ts # Data access & external calls
utils/
response.ts # Standardized response helpers
errors.ts # Custom error classes
types/
express.d.ts # Augmented Express types
index.ts # App entry point
Route Module Pattern
// src/routes/users.ts
import { Router, Request, Response } from 'express';
import { UserController } from '../controllers/userController.js';
const router = Router();
const userController = new UserController();
router.get('/', userController.list.bind(userController)); // GET /api/users
router.get('/:id', userController.getById.bind(userController)); // GET /api/users/:id
router.post('/', userController.create.bind(userController)); // POST /api/users
router.patch('/:id', userController.update.bind(userController)); // PATCH /api/users/:id
router.delete('/:id', userController.remove.bind(userController)); // DELETE /api/users/:id
export default router;
// src/index.ts (mount routes)
import userRoutes from './routes/users.js';
app.use('/api/users', userRoutes);
Middleware: The Real Power of Express
Built-in Middleware
app.use(express.json({ limit: '1mb' })); // Body parser (max 1MB)
app.use(express.urlencoded({ extended: false })); // Form data
app.use(express.static('public')); // Static files
app.use(express.text()); // Plain text bodies
app.use(express.raw()); // Raw Buffer bodies
Custom Middleware
// Request logging
function requestLogger(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
});
next();
}
// Request ID (traceability)
import { randomUUID } from 'crypto';
function requestId(req: Request, res: Response, next: NextFunction) {
const id = req.headers['x-request-id'] || randomUUID();
req.headers['x-request-id'] = id as string;
res.setHeader('X-Request-ID', id as string);
next();
}
// Timing
function responseTime(req: Request, res: Response, next: NextFunction) {
const start = process.hrtime.bigint();
res.on('finish', () => {
const ns = Number(process.hrtime.bigint() - start);
res.setHeader('X-Response-Time', `${(ns / 1e6).toFixed(2)}ms`);
});
next();
}
// Compose middleware
app.use(requestId);
app.use(requestLogger);
app.use(responseTime);
Authentication Middleware
import jwt from 'jsonwebtoken';
interface AuthRequest extends Request {
user?: { id: string; role: string };
}
function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Missing auth token' } });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { id: string; role: string };
req.user = decoded;
next();
} catch (err) {
if (err instanceof jwt.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 auth token' } });
}
}
// Role-based access
function authorize(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ error: { code: 'UNAUTHORIZED' } });
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: { code: 'FORBIDDEN', message: 'Insufficient permissions' } });
}
next();
};
}
// Usage:
app.get('/api/users', authenticate, authorize('admin'), userController.list);
Validation Middleware
import { z } from 'zod'; // or use joi/ajv
function validate<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}));
return res.status(422).json({ error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details: errors } });
}
req.body = result.data; // Use validated/sanitized data
next();
};
}
// Define schemas
const createUserSchema = z.object({
email: z.string().email('Invalid email format'),
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
password: z.string().min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase').regex(/[0-9]/, 'Must contain number'),
role: z.enum(['admin', 'editor', 'viewer']).optional().default('viewer'),
});
// Usage:
app.post('/api/users', authenticate, authorize('admin'), validate(createUserSchema), userController.create);
Rate Limiting
import rateLimit from 'express-rate-limit';
// Global rate limit
app.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Max 100 requests per window
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false,
keyGenerator: (req) => req.user?.id || req.ip!,
message: { error: { code: 'RATE_LIMITED', message: 'Too many requests, try again later' } },
}));
// Strict limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // Only 5 login attempts per 15 min
skipSuccessfulRequests: true, // Reset counter on success
});
app.post('/api/auth/login', authLimiter, authController.login);
Error Handling Architecture
// Custom error classes
class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: unknown[]
) {
super(message);
this.name = 'AppError';
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(404, 'NOT_FOUND', `${resource} not found`, [{ resource, id }]);
}
}
class ValidationError extends AppError {
constructor(details: unknown[]) {
super(422, 'VALIDATION_ERROR', 'Validation failed', details);
}
}
class ConflictError extends AppError {
constructor(message: string) {
super(409, 'CONFLICT', message);
}
}
// Global error handler
function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction) {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details,
requestId: req.headers['x-request-id'],
}
});
}
// Prisma errors (if using Prisma)
if (err.code === 'P2025') { // Record not found
return res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Record not found' } });
}
if (err.code === 'P2002') { // Unique constraint violation
return res.status(409).json({ error: { code: 'CONFLICT', message: 'Resource already exists' } });
}
// Unknown errors (log full, send safe message)
console.error(`[UNHANDLED] ${err.stack}`);
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An internal error occurred',
requestId: req.headers['x-request-id'],
}
});
}
// Async error wrapper (so you don't need try/catch in every handler)
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
}
// Usage: No need for try/catch!
app.get('/api/users/:id', asyncHandler(async (req, res) => {
const user = await userService.findById(req.params.id);
if (!user) throw new NotFoundError('User', req.params.id);
res.json({ success: true, data: user }));
}));
Performance Tips
// 1. Enable compression (huge impact!)
import compression from 'compression';
app.use(compression()); // 70%+ reduction in response size
// 2. Security headers
import helmet from 'helmet';
app.use(helmet());
// 3. CORS
import cors from 'cors';
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// 4. Graceful shutdown
const server = app.listen(PORT);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('Server closed');
process.exit(0);
});
// Force close after 30 seconds
setTimeout(() => process.exit(1), 30_000);
});
// 5. Trust proxy (if behind Nginx/Cloudflare)
app.set('trust proxy', 1);
// 6. Disable ETag for dynamic content (save CPU)
app.set('etag', false);
// 7. Connection keep-alive
server.keepAliveTimeout = 61_000; // Slightly > Cloudflare's 60s timeout
server.headersTimeout = 62_000;
Quick Checklist Before Shipping
□ All routes behind authentication where needed?
□ Input validated with schema (zod/joi)?
□ Rate limiting on all endpoints?
□ Error handler catches all unhandled errors?
□ CORS configured (not wildcard)?
□ Security headers (helmet)?
□ Compression enabled?
□ Graceful shutdown handler?
□ Health check endpoint?
□ Request logging?
□ Request IDs for traceability?
□ Environment variables validated on startup?
□ No sensitive data in responses or logs?
□ Database connection pooling configured?
□ Process manager (PM2/systemd) for production?
What's your Express middleware must-have? What's missing from this guide?
Follow @armorbreak for more practical developer guides.
Top comments (0)