Express.js
Express gives you routing, middleware, and HTTP utilities on top of Node.js and nothing else. No folder conventions, no DI system, no opinions on validation or auth β you design the architecture yourself. That freedom is exactly why Express became the default Node.js framework, and also why so many Express codebases are a mess six months in. Understanding how to structure it well matters as much as the framework itself.
π’ Junior
Setting Up from Scratch
mkdir my-api && cd my-api
npm init -y
npm install express
npm install -D nodemon # auto-restart on file changes
Add to package.json:
{
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
}
}
Basic server:
const express = require('express');
const app = express();
app.use(express.json()); // parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // parse form data
app.get('/', (req, res) => {
res.json({ message: 'Hello, world!' });
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
The Request Object (req)
Everything the client sent you:
app.post('/users/:id/orders', (req, res) => {
console.log(req.params.id); // URL parameters: /users/42/orders β "42"
console.log(req.query.page); // Query string: ?page=2 β "2"
console.log(req.body.name); // JSON body: { "name": "Alice" }
console.log(req.headers['authorization']); // Request headers
console.log(req.method); // "POST"
console.log(req.path); // "/users/42/orders"
console.log(req.ip); // Client IP (behind proxy: req.ips)
});
The Response Object (res)
Sending responses back:
// JSON response (sets Content-Type: application/json automatically)
res.json({ user: { id: 1, name: 'Alice' } });
// With status code
res.status(201).json({ id: newUser.id });
res.status(404).json({ error: 'User not found' });
res.status(204).send(); // No content (used after DELETE)
// Redirect
res.redirect(301, 'https://new-location.com');
// Send a file
res.sendFile(path.join(__dirname, 'report.pdf'));
// Set headers manually
res.set('X-Custom-Header', 'value');
res.set('Cache-Control', 'no-store');
Routing
// Method + path
app.get('/users', getAllUsers);
app.post('/users', createUser);
app.get('/users/:id', getUserById);
app.put('/users/:id', replaceUser);
app.patch('/users/:id', updateUser);
app.delete('/users/:id', deleteUser);
// Route parameters
app.get('/posts/:year/:month', (req, res) => {
const { year, month } = req.params; // { year: '2026', month: '05' }
res.json({ year, month });
});
// Query parameters (?search=alice&page=2)
app.get('/search', (req, res) => {
const { search = '', page = 1, limit = 20 } = req.query;
res.json({ search, page: +page, limit: +limit });
});
Express Router β group related routes into modules:
// routes/users.js
const router = require('express').Router();
router.get('/', getAllUsers);
router.post('/', createUser);
router.get('/:id', getUserById);
router.put('/:id', updateUser);
router.delete('/:id', deleteUser);
module.exports = router;
// app.js
app.use('/users', require('./routes/users'));
app.use('/posts', require('./routes/posts'));
Middleware
A middleware is any function with signature (req, res, next). It either modifies req/res and calls next(), or sends a response and stops the chain.
// Logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.path} ${res.statusCode} β ${Date.now() - start}ms`);
});
next(); // MUST call next() or send a response β otherwise request hangs forever
});
// Attach data to the request for downstream handlers
app.use((req, res, next) => {
req.requestId = crypto.randomUUID();
next();
});
// Route-level middleware β only runs for this route
app.get('/admin', requireAdmin, adminHandler);
// Chained middleware on a route
app.post('/users', validateBody(schema), checkPermission('users:create'), createUser);
Order is everything β middleware runs in the exact order it is registered.
Serving Static Files
// Serve everything in /public at the URL root
app.use(express.static('public'));
// public/logo.png is now accessible at /logo.png
// Serve at a specific URL prefix
app.use('/assets', express.static(path.join(__dirname, 'public')));
// public/logo.png β /assets/logo.png
π‘ Medior
Input Validation with Zod
Never trust user input. Validate and sanitize before touching your database.
npm install zod
const { z } = require('zod');
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['user', 'admin']).default('user'),
});
// Reusable validation middleware factory
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
req.validated = result.data; // use req.validated downstream β not req.body
next();
};
}
app.post('/users', validate(createUserSchema), async (req, res, next) => {
try {
const user = await userService.create(req.validated);
res.status(201).json(user);
} catch (err) {
next(err);
}
});
Error Handling
Express has a special 4-argument error middleware. It must be registered last.
// Sync errors β throw directly
app.get('/sync-error', (req, res) => {
throw new Error('Something broke'); // Express catches this automatically
});
// Async errors β MUST pass to next()
app.get('/async-error', async (req, res, next) => {
try {
const data = await db.query('SELECT ...');
res.json(data);
} catch (err) {
next(err); // Skips all route handlers, lands in the error middleware below
}
});
// Custom error class with HTTP status
class AppError extends Error {
constructor(message, status = 500, code = 'INTERNAL_ERROR') {
super(message);
this.status = status;
this.code = code;
}
}
class NotFoundError extends AppError {
constructor(resource) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
// Centralized error handler β LAST middleware, 4 arguments
app.use((err, req, res, next) => {
const status = err.status || 500;
const message = err.message || 'Internal server error';
// Log with request context
console.error({
requestId: req.requestId,
method: req.method,
path: req.path,
status,
error: err.stack,
});
res.status(status).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: status < 500 ? message : 'Something went wrong',
},
});
});
Express 5 (currently in beta) catches async errors automatically β no try/catch needed in handlers.
Authentication β JWT (Stateless)
npm install jsonwebtoken bcryptjs
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
// Sign a token on login
app.post('/auth/login', async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await userRepo.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
throw new AppError('Invalid credentials', 401, 'UNAUTHORIZED');
}
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // short-lived access token
);
const refreshToken = jwt.sign(
{ sub: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' } // long-lived refresh token
);
// Store refresh token in httpOnly cookie β not in localStorage (XSS risk)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
} catch (err) {
next(err);
}
});
// Protect routes with auth middleware
const requireAuth = (req, res, next) => {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return next(new AppError('Missing token', 401, 'UNAUTHORIZED'));
}
try {
req.user = jwt.verify(header.split(' ')[1], process.env.JWT_SECRET);
next();
} catch (err) {
next(new AppError('Invalid or expired token', 401, 'UNAUTHORIZED'));
}
};
// Role-based authorization
const requireRole = (...roles) => (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return next(new AppError('Insufficient permissions', 403, 'FORBIDDEN'));
}
next();
};
app.get('/admin/users', requireAuth, requireRole('admin'), listUsers);
Authentication β Sessions (Stateful)
npm install express-session connect-redis
const session = require('express-session');
const RedisStore = require('connect-redis').default;
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false, // don't save unchanged sessions
saveUninitialized: false, // don't save empty sessions (GDPR friendly)
cookie: {
httpOnly: true, // not accessible via document.cookie (XSS protection)
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
sameSite: 'strict', // CSRF protection
maxAge: 30 * 60 * 1000, // 30 minutes
},
}));
// Login endpoint
app.post('/auth/login', async (req, res, next) => {
try {
const user = await verifyCredentials(req.body);
req.session.userId = user.id;
req.session.userRole = user.role;
res.json({ message: 'Logged in' });
} catch (err) { next(err); }
});
// Middleware to protect routes
const requireSession = (req, res, next) => {
if (!req.session.userId) {
return next(new AppError('Not authenticated', 401, 'UNAUTHORIZED'));
}
next();
};
JWT vs Sessions:
- JWT β stateless, scales horizontally without shared storage, but cannot be revoked until expiry. Best for APIs consumed by mobile/SPA clients.
- Sessions β stateful, can be revoked instantly (just delete from Redis), simpler for traditional web apps.
Database Integration Pattern
Express has no opinion on databases. Hereβs a clean pattern with a service layer:
// repositories/userRepository.js
class UserRepository {
constructor(db) { this.db = db; }
async findById(id) {
const result = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0] || null;
}
async create({ name, email, passwordHash }) {
const result = await this.db.query(
'INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING *',
[name, email, passwordHash]
);
return result.rows[0];
}
}
// services/userService.js
class UserService {
constructor(userRepo) { this.userRepo = userRepo; }
async getUser(id) {
const user = await this.userRepo.findById(id);
if (!user) throw new NotFoundError('User');
return { id: user.id, name: user.name, email: user.email }; // never return password hash
}
}
// routes/users.js
router.get('/:id', requireAuth, async (req, res, next) => {
try {
const user = await userService.getUser(req.params.id);
res.json(user);
} catch (err) {
next(err);
}
});
Testing with Supertest
npm install -D jest supertest
// routes/users.test.js
const request = require('supertest');
const app = require('../app'); // export app without .listen()
describe('GET /users/:id', () => {
it('returns a user', async () => {
const res = await request(app)
.get('/users/1')
.set('Authorization', `Bearer ${validToken}`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: 1, name: 'Alice' });
});
it('returns 404 for unknown user', async () => {
const res = await request(app)
.get('/users/9999')
.set('Authorization', `Bearer ${validToken}`);
expect(res.status).toBe(404);
expect(res.body.error.code).toBe('NOT_FOUND');
});
it('returns 401 without a token', async () => {
const res = await request(app).get('/users/1');
expect(res.status).toBe(401);
});
});
π΄ Senior
Security Hardening
npm install helmet cors express-rate-limit
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
// Helmet sets security-related HTTP headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true },
}));
// CORS β explicitly whitelist allowed origins
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
}));
// Rate limiting β prevents brute-force and DDoS
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
app.use(globalLimiter);
// Stricter limit on auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many attempts, try again later' },
});
app.use('/auth', authLimiter);
Performance
npm install compression
const compression = require('compression');
app.use(compression()); // gzip all responses > 1kb
// Never use *Sync methods in request handlers β they block the entire event loop:
// β fs.readFileSync(...)
// β
await fs.promises.readFile(...)
// Cluster β fork one worker per CPU core
import cluster from 'cluster';
import { cpus } from 'os';
if (cluster.isPrimary) {
for (let i = 0; i < cpus().length; i++) cluster.fork();
cluster.on('exit', (worker) => {
console.warn(`Worker ${worker.id} died, restarting`);
cluster.fork();
});
} else {
startExpressServer(); // your normal app.listen(...)
}
// In practice: pm2 start app.js -i max does this without writing cluster code
Graceful Shutdown
const server = app.listen(port, () => console.log(`Listening on ${port}`));
const shutdown = async (signal) => {
console.log(`Received ${signal}, shutting down gracefullyβ¦`);
server.close(async () => { // stop accepting new connections
await db.pool.end(); // drain database pool
await redisClient.quit(); // close Redis connection
console.log('Shutdown complete');
process.exit(0);
});
// Force exit if graceful shutdown takes too long
setTimeout(() => process.exit(1), 10_000);
};
process.on('SIGTERM', () => shutdown('SIGTERM')); // Docker/Kubernetes stop
process.on('SIGINT', () => shutdown('SIGINT')); // Ctrl+C
Structured Project Layout
src/
config/ β env variables, DB config
middleware/ β auth, logging, validation, error-handler
routes/ β one file per resource (users.js, posts.js)
controllers/ β thin: parse req, call service, send res
services/ β business logic
repositories/ β data access (SQL/ORM queries)
models/ β schema definitions
errors/ β custom error classes
utils/ β shared helpers
app.js β express setup, middleware registration, route mounting
server.js β http.listen(), cluster, graceful shutdown
Keep controllers thin β they should only parse the request, call a service, and format the response. Business logic lives in services.
Express vs Alternatives
| Β | Express | Fastify | NestJS | Hono |
|---|---|---|---|---|
| Requests/sec | ~30k | ~60k | ~25k | ~100k+ |
| TypeScript | Manual | Built-in | First-class | First-class |
| Structure | DIY | Plugin-based | Angular-like | Minimal |
| Ecosystem | Largest | Growing | Enterprise | Edge-focused |
| Best for | Full control, legacy | High-throughput APIs | Large teams | Edge/serverless |
Senior Gotchas
- Forgetting
next(err)in async handlers β causes hanging requests that never respond. Consider wrapping all async route handlers with a try-catch wrapper utility. - Middleware order β
cors()must come before route handlers; body parsers must come before anything that readsreq.body; the error handler MUST be last. - Not setting
NODE_ENV=productionβ Express keeps development-only overhead (error stack traces in responses, view cache off) running in production. req.paramsvalues are always strings βreq.params.idfor a route/users/:idis"42", not42. Parse with+req.params.idorparseIntbefore using in DB queries.router.param()deduplication β userouter.param('id', lookupUser)to avoid repeating the same DB lookup in every route that uses:id.- Unhandled promise rejections crash Node 15+ β always either
try/catchasync handlers or use a wrapper. Express 5 fixes this automatically. appvsserverβapp.listen()returns the underlyinghttp.Server. If you need WebSocket or graceful shutdown control, save the reference:const server = app.listen(...).