Express.js
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.
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.
| 1 | npm init -y |
| 2 | npm install express |
| 3 | cat > app.js << 'EOF' |
| 4 | import express from 'express'; |
| 5 | |
| 6 | const app = express(); |
| 7 | |
| 8 | app.get('/health', (req, res) => { |
| 9 | res.json({ status: 'ok', uptime: process.uptime() }); |
| 10 | }); |
| 11 | |
| 12 | export default app; |
| 13 | EOF |
| 1 | // server.js — bootstraps the network listener |
| 2 | import app from './app.js'; |
| 3 | |
| 4 | const PORT = process.env.PORT || 3000; |
| 5 | const server = app.listen(PORT, () => { |
| 6 | console.log(`Server listening on port ${PORT}`); |
| 7 | }); |
| 8 | |
| 9 | process.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
Project Structure
A scalable Express project groups related concerns into directories. The following layout is common in production codebases:
| Directory | Purpose |
|---|---|
| src/routes | Route definitions grouped by domain |
| src/middleware | Reusable middleware (auth, logging, errors) |
| src/controllers | Request handlers separated from routing |
| src/services | Business logic and external integrations |
| src/lib | Utility functions and configuration |
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.
| 1 | // Middleware signature |
| 2 | function 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 |
| 8 | function errorHandler(err, req, res, next) { |
| 9 | console.error(err.stack); |
| 10 | res.status(500).json({ error: 'Internal server error' }); |
| 11 | } |
| 12 | |
| 13 | app.use(logger); |
| 14 | app.use(express.json()); |
| 15 | app.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 Type | Scope | Example |
|---|---|---|
| Application-level | All routes | app.use(express.json()) |
| Router-level | Routes in a router | router.use(authMiddleware) |
| Route-level | Specific route | app.get('/', middleware, handler) |
| Error-handling | After routes | app.use((err, req, res, next) => ...) |
warning
Built-in Middleware
Express ships with three built-in middleware functions. Use them instead of reimplementing body parsing or static file serving.
| 1 | // Parse JSON bodies (based on body-parser) |
| 2 | app.use(express.json({ limit: '10kb' })); |
| 3 | |
| 4 | // Parse URL-encoded form data |
| 5 | app.use(express.urlencoded({ extended: true, limit: '10kb' })); |
| 6 | |
| 7 | // Serve static files from the public directory |
| 8 | app.use(express.static('public', { |
| 9 | maxAge: '1d', |
| 10 | etag: true, |
| 11 | lastModified: true, |
| 12 | })); |
| 13 | |
| 14 | // Raw binary bodies |
| 15 | app.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.
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.
| 1 | import { Router } from 'express'; |
| 2 | import * as userController from '../controllers/users.js'; |
| 3 | import { authenticate } from '../middleware/auth.js'; |
| 4 | |
| 5 | const router = Router(); |
| 6 | |
| 7 | // Collection routes |
| 8 | router.get('/', userController.listUsers); |
| 9 | router.post('/', authenticate, userController.createUser); |
| 10 | |
| 11 | // Single resource routes with parameter validation |
| 12 | router.get('/:id', userController.getUser); |
| 13 | router.put('/:id', authenticate, userController.updateUser); |
| 14 | router.patch('/:id', authenticate, userController.partialUpdateUser); |
| 15 | router.delete('/:id', authenticate, userController.deleteUser); |
| 16 | |
| 17 | export default router; |
| 1 | // app.js — mount routers |
| 2 | import express from 'express'; |
| 3 | import userRoutes from './routes/users.js'; |
| 4 | import orderRoutes from './routes/orders.js'; |
| 5 | |
| 6 | const app = express(); |
| 7 | |
| 8 | app.use(express.json()); |
| 9 | app.use('/users', userRoutes); |
| 10 | app.use('/orders', orderRoutes); |
| 11 | |
| 12 | export 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.
| 1 | app.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
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.
| 1 | // BAD: unhandled rejection will hang the request |
| 2 | app.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 |
| 8 | function asyncHandler(fn) { |
| 9 | return (req, res, next) => { |
| 10 | Promise.resolve(fn(req, res, next)).catch(next); |
| 11 | }; |
| 12 | } |
| 13 | |
| 14 | app.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
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.
| 1 | class 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 |
| 11 | function 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 | |
| 31 | app.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
| 1 | // 404 handler for unmatched routes |
| 2 | app.use((req, res, next) => { |
| 3 | res.status(404).json({ error: 'Route not found' }); |
| 4 | }); |
| 5 | |
| 6 | // Centralized error handler must come last |
| 7 | app.use(errorHandler); |
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.
| 1 | import { z } from 'zod'; |
| 2 | |
| 3 | const 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 | |
| 10 | function 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 | |
| 24 | app.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.
| Library | Strengths | Best For |
|---|---|---|
| Zod | Type inference, composable schemas | TypeScript projects |
| Joi | Mature, rich API, object references | JavaScript-heavy codebases |
| express-validator | Express-specific middleware chains | Projects already using validator.js |
| class-validator | Decorators, integrates with TypeORM | NestJS or class-based models |
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.
| 1 | import express from 'express'; |
| 2 | import helmet from 'helmet'; |
| 3 | import cors from 'cors'; |
| 4 | import rateLimit from 'express-rate-limit'; |
| 5 | |
| 6 | const app = express(); |
| 7 | |
| 8 | // Security headers |
| 9 | app.use(helmet()); |
| 10 | |
| 11 | // CORS with explicit origin |
| 12 | app.use(cors({ |
| 13 | origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000', |
| 14 | credentials: true, |
| 15 | })); |
| 16 | |
| 17 | // Rate limiting |
| 18 | const 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 | }); |
| 25 | app.use('/api/', limiter); |
| 26 | |
| 27 | // Stricter limit for authentication endpoints |
| 28 | const authLimiter = rateLimit({ |
| 29 | windowMs: 60 * 60 * 1000, |
| 30 | max: 5, |
| 31 | }); |
| 32 | app.use('/api/login', authLimiter); |
| 33 | app.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
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.
Before deploying an Express application to production, verify that these baseline protections and observability measures are in place.
| Concern | Action |
|---|---|
| Process management | Run behind PM2, systemd, or a container orchestrator |
| HTTPS | Terminate TLS at the load balancer or reverse proxy |
| Health checks | Expose a lightweight /health endpoint |
| Graceful shutdown | Close the server and database connections on SIGTERM |
info