|$ curl https://forge-ai.dev/api/markdown?path=docs/nodejs/express
$cat docs/express.js.md
updated Recently·20-28 min read·published

Express.js

Node.jsExpressIntermediate🎯Free Tools
Introduction

Express.js is the most widely used web framework for Node.js. It provides a thin layer of fundamental web application features on top of Node.js's built-in http module, making it straightforward to build APIs, server-rendered applications, and microservices without imposing a rigid architecture.

At its core, Express is a middleware framework. An incoming request flows through a series of functions that can inspect the request, modify the response, execute side effects, or terminate the cycle early. Understanding this middleware pipeline is the key to writing maintainable, secure, and performant Express applications.

This guide covers real-world Express patterns used in production: application bootstrapping, route organization, asynchronous error handling, input validation, security headers, and the mistakes that cause outages when applications scale.

Setting Up an Express Application

A production Express application starts with more than a single app.listen() call. Separate server creation from application logic so tests can import the configured app without starting a network listener.

init.sh
Bash
1npm init -y
2npm install express
3cat > app.js << 'EOF'
4import express from 'express';
5
6const app = express();
7
8app.get('/health', (req, res) => {
9 res.json({ status: 'ok', uptime: process.uptime() });
10});
11
12export default app;
13EOF
server.js
JavaScript
1// server.js — bootstraps the network listener
2import app from './app.js';
3
4const PORT = process.env.PORT || 3000;
5const server = app.listen(PORT, () => {
6 console.log(`Server listening on port ${PORT}`);
7});
8
9process.on('SIGTERM', () => {
10 console.log('SIGTERM received, closing server gracefully');
11 server.close(() => {
12 process.exit(0);
13 });
14});

Separating app.js from server.js enables testing with libraries like Supertest without occupying a real port. It also makes graceful shutdown easier because the server instance is accessible for server.close().

best practice

Never call app.listen() at the top level of the file that defines your routes and middleware. Export the configured app and start the listener in a dedicated entry point. This small separation pays off immediately when writing integration tests.

Project Structure

A scalable Express project groups related concerns into directories. The following layout is common in production codebases:

DirectoryPurpose
src/routesRoute definitions grouped by domain
src/middlewareReusable middleware (auth, logging, errors)
src/controllersRequest handlers separated from routing
src/servicesBusiness logic and external integrations
src/libUtility functions and configuration
Middleware in Depth

Middleware functions in Express have access to the request object req, the response object res, and the next function that passes control to the next middleware. They can end the request-response cycle or call next() to continue processing.

middleware.js
JavaScript
1// Middleware signature
2function logger(req, res, next) {
3 console.log(`${req.method} ${req.url} - ${new Date().toISOString()}`);
4 next(); // pass control to the next middleware
5}
6
7// Error-handling middleware has four arguments
8function errorHandler(err, req, res, next) {
9 console.error(err.stack);
10 res.status(500).json({ error: 'Internal server error' });
11}
12
13app.use(logger);
14app.use(express.json());
15app.use(errorHandler);

The order in which middleware is registered matters. A request flows through middleware in the order it was added. If an authentication middleware runs after a route handler, the route handler will execute before authentication is enforced.

Middleware TypeScopeExample
Application-levelAll routesapp.use(express.json())
Router-levelRoutes in a routerrouter.use(authMiddleware)
Route-levelSpecific routeapp.get('/', middleware, handler)
Error-handlingAfter routesapp.use((err, req, res, next) => ...)

warning

Forgetting to call next() in a middleware function will hang the request until the client times out. Conversely, calling next() after sending a response will cause ERR_HTTP_HEADERS_SENT if another handler tries to respond.

Built-in Middleware

Express ships with three built-in middleware functions. Use them instead of reimplementing body parsing or static file serving.

built-in-middleware.js
JavaScript
1// Parse JSON bodies (based on body-parser)
2app.use(express.json({ limit: '10kb' }));
3
4// Parse URL-encoded form data
5app.use(express.urlencoded({ extended: true, limit: '10kb' }));
6
7// Serve static files from the public directory
8app.use(express.static('public', {
9 maxAge: '1d',
10 etag: true,
11 lastModified: true,
12}));
13
14// Raw binary bodies
15app.use(express.raw({ type: 'application/octet-stream', limit: '1mb' }));

Always set size limits on body parsers. Without limits, a malicious client can send a multi-megabyte JSON payload and exhaust process memory. The extended: true option enables rich parsing of nested objects via the qs library.

Routing

Express routing maps HTTP methods and URL patterns to handler functions. Routes can use string paths, route parameters, regular expressions, and chained handlers. Clean routing separates HTTP concerns from business logic.

routes/users.js
JavaScript
1import { Router } from 'express';
2import * as userController from '../controllers/users.js';
3import { authenticate } from '../middleware/auth.js';
4
5const router = Router();
6
7// Collection routes
8router.get('/', userController.listUsers);
9router.post('/', authenticate, userController.createUser);
10
11// Single resource routes with parameter validation
12router.get('/:id', userController.getUser);
13router.put('/:id', authenticate, userController.updateUser);
14router.patch('/:id', authenticate, userController.partialUpdateUser);
15router.delete('/:id', authenticate, userController.deleteUser);
16
17export default router;
app.js
JavaScript
1// app.js — mount routers
2import express from 'express';
3import userRoutes from './routes/users.js';
4import orderRoutes from './routes/orders.js';
5
6const app = express();
7
8app.use(express.json());
9app.use('/users', userRoutes);
10app.use('/orders', orderRoutes);
11
12export default app;

Route Parameters and Query Strings

Route parameters are named URL segments prefixed with a colon. req.params contains captured values, while req.query contains parsed query string parameters.

route-params.js
JavaScript
1app.get('/products/:id/reviews/:reviewId', (req, res) => {
2 const { id, reviewId } = req.params;
3 const { sort = 'newest', limit = '20' } = req.query;
4
5 res.json({
6 productId: id,
7 reviewId,
8 sort,
9 limit: Number(limit),
10 });
11});
📝

note

Route parameter values are always strings. If you expect numeric IDs, validate and coerce them explicitly with Number() or a schema library. Never pass req.params.id directly into a SQL query.
Asynchronous Route Handlers

Express 4 does not automatically catch rejected promises in route handlers. If an async handler throws and you do not handle the error, the request hangs and the client eventually times out. Express 5 changes this behavior, but most production codebases still run Express 4.

async-handlers.js
JavaScript
1// BAD: unhandled rejection will hang the request
2app.get('/users/:id', async (req, res) => {
3 const user = await db.users.findById(req.params.id); // if this rejects, Express does nothing
4 res.json(user);
5});
6
7// GOOD: wrap async handlers
8function asyncHandler(fn) {
9 return (req, res, next) => {
10 Promise.resolve(fn(req, res, next)).catch(next);
11 };
12}
13
14app.get('/users/:id', asyncHandler(async (req, res) => {
15 const user = await db.users.findById(req.params.id);
16 if (!user) {
17 const error = new Error('User not found');
18 error.statusCode = 404;
19 throw error;
20 }
21 res.json(user);
22}));

The wrapper forwards any rejected promise to the next error-handling middleware via next(err). This pattern is so common that libraries like express-async-handler provide the same utility. Alternatively, upgrade to Express 5 to get native async error handling.

danger

Never mix async/await with callbacks that call next() unless you understand the control flow. A callback invoking next() after an awaited operation has already sent a response will trigger ERR_HTTP_HEADERS_SENT.
Error Handling

Express error-handling middleware is defined with four arguments instead of three. The presence of the fourth argument tells Express that this middleware handles errors. Error handlers should be registered after all other middleware and routes.

error-handler.js
JavaScript
1class AppError extends Error {
2 constructor(message, statusCode) {
3 super(message);
4 this.statusCode = statusCode;
5 this.isOperational = true;
6 Error.captureStackTrace(this, this.constructor);
7 }
8}
9
10// Centralized error handler
11function errorHandler(err, req, res, next) {
12 const statusCode = err.statusCode || 500;
13 const message = err.message || 'Internal server error';
14
15 console.error({
16 message: err.message,
17 stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
18 url: req.originalUrl,
19 method: req.method,
20 ip: req.ip,
21 });
22
23 res.status(statusCode).json({
24 error: {
25 message,
26 ...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
27 },
28 });
29}
30
31app.use(errorHandler);

Distinguish operational errors from programmer errors. Operational errors, such as invalid input or missing resources, should be communicated to the client with a meaningful status code. Programmer errors, such as undefined variable access, should be logged and return a generic 500 response without leaking implementation details.

best practice

Always include a catch-all 404 handler for unmatched routes. Without it, clients receive an empty 200 response or a generic Express 404 HTML page, neither of which is appropriate for a JSON API.
404-handler.js
JavaScript
1// 404 handler for unmatched routes
2app.use((req, res, next) => {
3 res.status(404).json({ error: 'Route not found' });
4});
5
6// Centralized error handler must come last
7app.use(errorHandler);
Input Validation Patterns

Validating input at the boundary of your application prevents malformed data from reaching business logic and protects against injection attacks. Zod is a popular choice for TypeScript projects because it infers static types from schemas.

validation.js
JavaScript
1import { z } from 'zod';
2
3const createUserSchema = z.object({
4 email: z.string().email(),
5 name: z.string().min(1).max(100),
6 age: z.number().int().min(13).optional(),
7 role: z.enum(['user', 'admin']).default('user'),
8});
9
10function validate(schema) {
11 return (req, res, next) => {
12 const result = schema.safeParse(req.body);
13 if (!result.success) {
14 const error = new Error('Validation failed');
15 error.statusCode = 400;
16 error.details = result.error.flatten();
17 return next(error);
18 }
19 req.validated = result.data;
20 next();
21 };
22}
23
24app.post('/users', validate(createUserSchema), asyncHandler(async (req, res) => {
25 const user = await db.users.create(req.validated);
26 res.status(201).json(user);
27}));

Validation should cover request bodies, query parameters, route parameters, and headers. Coerce types explicitly: query parameters are strings, but your schema may expect numbers or booleans. Zod provides z.coerce.number() for this purpose.

LibraryStrengthsBest For
ZodType inference, composable schemasTypeScript projects
JoiMature, rich API, object referencesJavaScript-heavy codebases
express-validatorExpress-specific middleware chainsProjects already using validator.js
class-validatorDecorators, integrates with TypeORMNestJS or class-based models
Security Basics

Express ships with minimal security defaults. A production API needs explicit protection against common web vulnerabilities, including injection, cross-site scripting, and cross-origin misuse.

security.js
JavaScript
1import express from 'express';
2import helmet from 'helmet';
3import cors from 'cors';
4import rateLimit from 'express-rate-limit';
5
6const app = express();
7
8// Security headers
9app.use(helmet());
10
11// CORS with explicit origin
12app.use(cors({
13 origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000',
14 credentials: true,
15}));
16
17// Rate limiting
18const limiter = rateLimit({
19 windowMs: 15 * 60 * 1000, // 15 minutes
20 max: 100, // limit each IP to 100 requests per windowMs
21 standardHeaders: true,
22 legacyHeaders: false,
23 message: { error: 'Too many requests, please try again later.' },
24});
25app.use('/api/', limiter);
26
27// Stricter limit for authentication endpoints
28const authLimiter = rateLimit({
29 windowMs: 60 * 60 * 1000,
30 max: 5,
31});
32app.use('/api/login', authLimiter);
33app.use('/api/register', authLimiter);

Helmet sets security headers such as Content-Security-Policy, X-Frame-Options, and X-Content-Type-Options. CORS should never use origin: '* when cookies or authorization headers are involved. Rate limiting by IP mitigates brute-force and denial-of-service attacks.

warning

Trusting the X-Forwarded-For header without configuring app.set('trust proxy', ...) can allow attackers to bypass IP-based rate limiting. Read the Express documentation on trust proxy before deploying behind a load balancer.
Common Mistakes

Even experienced developers make these mistakes in Express applications. Recognizing them early prevents subtle bugs and production incidents.

Missing error handling in async routes

Express 4 does not catch rejected promises. Always wrap async handlers or use a utility like express-async-handler.

Calling next() after sending a response

Once res.send(), res.json(), or res.render() is called, do not call next() or attempt to send additional data.

Trusting user input

Always validate and sanitize data from req.body, req.params, req.query, and headers. Never concatenate user input into raw SQL or shell commands.

No request size limits

Default body parsers accept large payloads. Set limit options appropriate for each endpoint to prevent memory exhaustion.

Production Checklist

Before deploying an Express application to production, verify that these baseline protections and observability measures are in place.

ConcernAction
Process managementRun behind PM2, systemd, or a container orchestrator
HTTPSTerminate TLS at the load balancer or reverse proxy
Health checksExpose a lightweight /health endpoint
Graceful shutdownClose the server and database connections on SIGTERM

info

Use a reverse proxy such as Nginx, Caddy, or a cloud load balancer in front of Express. It handles TLS termination, compression, static file caching, and request buffering more efficiently than Node.js can alone.
$Blueprint — Engineering Documentation·Section ID: NODE-EXP-01·Revision: 1.0