1. Introduction
1.1 What is Express.js?
Express.js is a lightweight and flexible framework built on top of Node.js. It’s mainly used for creating web servers and APIs. Instead of forcing you into a fixed structure, Express gives you simple building blocks like routes and middleware, so you can design your application the way you want. Think of it as a toolkit that helps you quickly handle requests, responses, and everything in between.
1.2 Why use Express v5?
v5 keeps the small core but simplifies async code: thrown errors / rejected promises in async handlers are forwarded to the error handler automatically, reducing boilerplate. Most v4 code remains compatible, but v5 removes a few legacy patterns.
2. Setup & Installation
2.1 Install Express v5
Install and init a project.
npm init -y
npm install express@5
2.2 Basic server (CJS / ESM)
Create and export app
in a module; start server from server.js
so tests can import app
without binding a port.
app.js (ESM)
import express from 'express';
const app = express();
export default app;
server.js
import app from './app.js';
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => console.log(`Listening ${PORT}`));
process.on('SIGTERM', () => server.close(() => process.exit(0)));
3. Project Structure & Best Practices
3.1 Recommended layout
Keep a predictable folder structure: controllers, routes, services, middlewares, utils, config.
src/
app.js
server.js
routes/
controllers/
services/
middlewares/
utils/
3.2 Why this layout?
Thin routes → controllers → services → data layer keeps code testable and maintainable; you can mock services in unit tests.
4. Core Concepts (app
, req
, res
)
4.1 app
object
Think of the app
object as the brain of your Express app.
It’s where you plug in middleware, define routes, and set configurations.
With methods like app.use()
, app.get()
, and app.set()
, you’re basically teaching your server how to behave.
4.2 req
(request) overview
The req
object represents the incoming request from the client.
This is where you’ll find everything the user is sending to your server:
req.params
→ URL parametersreq.query
→ query stringsreq.body
→ form or JSON datareq.headers
→ request headersreq.ip
→ client’s IP address
4.3 res
(response) overview
The res
object is how your server talks back to the client.
You can send text, JSON, files, or even redirect users.
Some of the most common helpers are:
res.send()
→ send plain text or HTMLres.json()
→ send JSON datares.status()
→ set HTTP status codesres.redirect()
→ send the client to another URLres.sendFile()
/res.download()
→ serve files
5. Middleware
Middleware is the chain of functions that a request goes through before a response is sent. Each one can read or change req
/res
, run things like auth or logging, and then either call next()
or end the response. Order matters. In Express v5, async errors automatically go to the error handler.
5.1 Built-in middleware
Express v5 already comes with some common middleware, so you don’t need body-parser
anymore.
app.use(express.json()); // parse JSON
app.use(express.urlencoded({ extended: true })); // parse form-encoded data
app.use(express.static('public')); // serve static files
📝 V5 TIP
If you don’t add these, req.body
will stay undefined
5.2 Custom middleware
You can also write your own. A common example is logging or adding custom behavior.
function logger(req, res, next) {
console.log(`${req.method} ${req.originalUrl}`);
next();
}
app.use(logger);
5.3 Third-party middleware
There’s a whole ecosystem of middleware you can plug in. Some popular ones are:
morgan
for logginghelmet
for security headerscors
for cross-origin requestsexpress-rate-limit
for rate limitingcompression
for gzip compressionhpp
to prevent HTTP parameter pollution
📌 NOTE
The order still matters: start with security, then parsers, then your app-specific middleware.
6. Routing
Routing is basically how your app decides what to do when a request hits a certain URL. You tell Express: “If someone visits this path with this HTTP method, run this function.”
6.1 Basic routing (HTTP verbs)
You can handle different methods like GET
or POST
easily:
app.get('/', (req, res) => res.send('Home'));
app.post('/submit', (req, res) => res.send('Submitted'));
6.2 Route parameters (req.params
)
Need to grab part of the URL (like a user ID)? Just use :id
:
app.get('/users/:id', (req, res) => res.json({ id: req.params.id }));
6.3 Query strings (req.query
)
For things like search or pagination, you’ll get values from the query string. Remember: they come in as strings.
app.get('/search', (req, res) => res.json({ q: req.query.q }));
6.4 Route chaining (app.route()
)
If you’ve got multiple methods for the same path, you can group them nicely:
app.route('/articles')
.get(listArticles)
.post(createArticle);
6.5 app.all()
and wildcards
app.all()
runs on every method. Wildcards let you match patterns like “anything after this path.”
app.all('/maintenance', (req, res) => res.sendStatus(503));
app.get('/files/*', (req, res) => res.send('file pattern'));
6.6 Multiple handlers & arrays
You can pass multiple middleware/handlers to a route to build a flow:
const guards = [authenticate, requireAdmin];
app.delete('/users/:id', guards, deleteUser);
7. express.Router()
& Modular Routes
Routers are like mini-apps inside your app. They help you keep things organized. for example, separate routers for users, admin, or APIs.
7.1 Why routers?
Instead of cramming all routes into app.js
, you can split them up:
const router = express.Router();
router.get('/', (req, res) => res.send('API root'));
app.use('/api', router);
7.2 Router-level middleware & mounting
You can attach middleware to a router so it only applies there:
const admin = express.Router();
admin.use(requireAuth, adminOnly);
admin.get('/dashboard', dashboard);
app.use('/admin', admin);
7.3 router.param()
preloading
Handy trick: preload data whenever a route uses a certain parameter, so you don’t repeat DB code everywhere.
router.param('id', async (req, res, next, id) => {
req.user = await User.findById(id);
if (!req.user) return next(new HttpError(404, 'Not found'));
next();
});
💡 V5 TIP
If you throw inside an async router.param()
, Express will automatically pass it to the error handler.
8. Advanced Routing Patterns
8.1 Multiple handlers & in-route guards
You can chain handlers inside a single route, including guards that decide whether to continue.
app.get('/profile',
authenticate,
loadUser,
(req, res, next) => req.user.active ? next() : res.status(403).send('Inactive'),
(req, res) => res.json(req.user)
);
8.2 Router-level guard + mounting
Group routes like /admin
under a router and protect them all with middleware.
9. Body Parsing & Forms
Most of the time, your app needs to read data that comes in through requests. Express makes this easy with body parsers.
9.1 JSON & URL-encoded forms
For JSON payloads, just use express.json()
. For HTML form data, use express.urlencoded({ extended: true })
. Place these early in your middleware chain so req.body is ready for routes.
app.use(express.json()); // for JSON
app.use(express.urlencoded({ extended: true })); // for form data
9.2 Large bodies & limits
If users send big payloads, it’s smart to set a limit so your server doesn’t get overloaded.
app.use(express.json({ limit: '1mb' }));
10. File Uploads (multer)
Uploading files is another common use case. The popular library multer
helps handle multipart/form-data
.
10.1 Basic usage
You can start with simple uploads like this:
import multer from 'multer';
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
res.send('Uploaded');
});
Here, uploaded files are stored in the uploads folder and you can access them through req.file or req.files.
10.2 Advanced: memory storage, filters, size limits
If you don’t want to store files on disk, you can use memory storage and then forward them to something like S3 or Cloudinary. You can also filter file types or set size limits.
const storage = multer.memoryStorage();
const uploadMem = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => /^image\//.test(file.mimetype)
? cb(null, true)
: cb(new Error('Images only'))
});
☁️ TIP
Directly upload from memory buffer to cloud storage when working with containers that don’t keep files around.
11. Static Files & SPA Fallback
11.1 Serving static directories
Want to serve images, CSS, or JS files? Just point Express to a folder.
app.use(express.static('public'));
11.2 SPA fallback
For single-page apps, serve index.html
after API routes.
app.use(express.static('public', { index: false }));
app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
⚠️ IMPORTANT
Place this fallback at the end so it doesn’t override your APIs.
12. Template Engines (SSR)
Sometimes you need to render HTML on the server. Express works with engines like EJS, Pug, or Handlebars.
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.get('/', (req, res) => res.render('index', { title: 'Home' }));
🔒 SECURITY
Sanitize user content and prefer auto-escaping engines.
13. Cookies & Signed Cookies
Cookies are useful for storing small pieces of data on the client.
13.1 Read & write cookies
The cookie-parser middleware makes it simple to set and read cookies.
import cookieParser from 'cookie-parser';
app.use(cookieParser(process.env.COOKIE_SECRET));
res.cookie('theme', 'dark', { httpOnly: true, sameSite: 'lax', secure: true });
13.2 Signed cookies
You can also sign cookies to make sure they haven’t been tampered with.
res.cookie('sid', 'val', { signed: true });
Later, read it with req.signedCookies.
🔑 SECURITY
If you’re setting cookies with SameSite=None
, make sure to also set secure: true
.
14. Sessions & Session Stores
When you want to keep track of users across multiple requests (like staying logged in), sessions come into play. Express makes this easy with the express-session
package.
In production, you should always use a proper session store like Redis or MongoDB instead of relying on the default memory option.
import session from 'express-session';
import MongoStore from 'connect-mongo';
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: MongoStore.create({ mongoUrl: process.env.MONGO_URL }),
cookie: { httpOnly: true, secure: true, sameSite: 'lax' }
}));
Why not MemoryStore?
MemoryStore is single-process and not persistent. it leaks memory and loses sessions on restart.
15. Authentication
There are two common approaches in Express apps:
15.1 JWT (stateless)
JWT is a simple way to handle authentication without storing sessions on the server. A token is issued on login and verified with each request.
import jwt from 'jsonwebtoken';
const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' });
// verify in middleware:
function requireAuth(req, res, next) {
const [, token] = (req.get('Authorization') || '').split(' ');
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
}
catch { res.sendStatus(401); }
}
15.2 Passport (pluggable strategies)
Passport is a popular library that makes it easier to implement different login methods such as local username and password, Google, GitHub, and many others.
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
passport.use(new LocalStrategy(async (u, p, done) => {
const user = await User.verify(u, p);
done(null, user);
}));
app.use(passport.initialize());
16. Validation & Sanitization
16.1 express-validator example
Always validate and sanitize incoming data at the route level. This keeps your app secure and ensures that the data you work with is clean.
import { body, validationResult } from 'express-validator';
app.post('/signup',
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
// continue
}
);
✅ TIP
Normalize and convert values when possible, and make sure to return user-friendly error messages.
17. CORS (Cross-Origin Resource Sharing)
CORS allows your server to accept requests from different origins. In development, you can keep it open, but in production, always restrict it to trusted domains.
import cors from 'cors';
app.use(cors({ origin: ['https://example.com'], credentials: true }));
🌍 NOTE
In dev you can be permissive; restrict origins
in production.
18. Rate Limiting & Abuse Protection
Rate limiting prevents abuse by restricting how many requests a user can make in a certain time window. You can use the default in-memory option for small apps or switch to Redis for larger apps.
import rateLimit from 'express-rate-limit';
app.use(rateLimit({ windowMs: 15*60*1000, max: 100 }));
⚡ ADVANCED
Apply stricter limits on sensitive endpoints like login, while keeping looser rules for general APIs.
19. Security Hardening (Helmet, CSP, HPP)
19.1 Helmet
Helmet adds helpful security headers that reduce common vulnerabilities.
import helmet from 'helmet';
app.use(helmet());
19.2 Content Security Policy (CSP)
CSP lets you control which scripts, styles, and other resources can load on your site. Nonces or hashes are safer choices if you need inline scripts.
19.3 HPP
HTTP Parameter Pollution (HPP) can cause weird behavior when query parameters are repeated. The hpp middleware blocks these attempts.
import hpp from 'hpp';
app.use(hpp());
20. Compression & Performance
20.1 Compression middleware
Enable compression to reduce response size and improve performance for clients.
import compression from 'compression';
app.use(compression());
20.2 Static file hints
When serving static assets, make sure they are fingerprinted and cached with long expiry times. Use the immutable flag for best results.
21. Caching & ETags
21.1 Cache-Control & ETag
Set Cache-Control
headers to control caching. Express automatically generates ETags for responses like res.send
and res.json
.
res.set('Cache-Control', 'public, max-age=60');
📦 TIP
Put static files behind a CDN for long-term caching, and keep API responses cached for shorter periods.
22. Streaming & Downloads
22.1 Backpressure-safe streaming
Use pipeline()
when streaming files or database results so data flows safely without memory issues.
import { pipeline } from 'stream';
import fs from 'fs';
app.get('/video', (req, res, next) => {
pipeline(fs.createReadStream('movie.mp4'), res, next);
});
22.2 Downloads & attachments
res.download()
or res.attachment()
helps when you want the browser to download files instead of just displaying them.
23. Logging & Observability
23.1 Access logs + structured logs
morgan
is great for HTTP access logs, and tools like winston
or pino
help structure your app logs.
import morgan from 'morgan';
import winston from 'winston';
const logger = winston.createLogger({ transports: [new winston.transports.Console()] });
app.use(morgan('combined', { stream: { write: msg => logger.info(msg.trim()) } }));
23.2 Request IDs & correlation
Adding request IDs makes it easier to trace a single request across multiple services.
24. Debugging & Dev Tools
24.1 DEBUG env
Set DEBUG=express:*
to see detailed logs from Express internals.
24.2 Auto-reload
Use nodemon
or ts-node-dev
while developing so the server restarts automatically when you change files.
25. Testing Patterns
25.1 Unit & integration with Supertest
Export your Express app in tests so supertest
can hit routes directly without needing the server to listen.
import request from 'supertest';
import app from '../src/app';
test('GET /health', () => request(app).get('/health').expect(200));
25.2 Test DBs & isolation
Run tests against a dedicated test database or use transactions. Reset or seed data for each test to keep them independent.
26. WebSockets & Realtime (Socket.IO)
26.1 Attach to HTTP server
Attach Socket.IO to an HTTP server to handle realtime communication.
import http from 'http';
import { Server } from 'socket.io';
const server = http.createServer(app);
const io = new Server(server, { cors: { origin: '*' } });
io.on('connection', socket => socket.on('ping', () => socket.emit('pong')));
server.listen(process.env.PORT || 3000);
🔗 TIP
Secure your sockets
by validating JWTs or sessions during the handshake.
27. OpenAPI / Swagger Docs
27.1 Quick integration
Use swagger-jsdoc
to generate docs and serve them with swagger-ui-express
.
import swaggerUi from 'swagger-ui-express';
import swaggerJsdoc from 'swagger-jsdoc';
const spec = swaggerJsdoc({ definition: { openapi: '3.0.0', info: { title: 'API', version: '1.0.0' } }, apis: ['./routes/*.js'] });
app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));
🤝 WHY
This gives you a nice interactive UI for APIs that helps both backend and frontend developers.
28. API Versioning & Modularity
28.1 Mount versioned routers
You can keep old clients working while shipping new changes by mounting versioned routers.
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
28.2 Deprecation headers
When phasing out an API, send headers to let clients know ahead of time.
res.set('Deprecation', 'true');
res.set('Sunset', '2025-12-31');
29. TypeScript + Express Patterns
29.1 Typing Request generics
Annotating params
, query
, and body
makes your code safer and more predictable.
import express, { Request, Response } from 'express';
interface Body {
email: string;
password: string
}
app.post('/signup', (req: Request<{}, {}, Body>, res: Response) => res.json(req.body));
29.2 Typed error middleware
Define types for your errors and use a handler that matches them so TypeScript can catch mistakes early.
30. Deployment & Ops
30.1 Graceful shutdown & signals
Close the server on shutdown signals so ongoing requests can finish cleanly.
const server = app.listen(PORT);
process.on('SIGTERM', () => server.close(() => process.exit(0)));
30.2 Process managers & reverse proxies
Run your app with PM2, Docker, or Kubernetes for process management. Use Nginx as a reverse proxy for TLS, buffering, and caching. Don’t forget app.set('trust proxy', 1)
when behind proxies.
31. Performance & Scaling
31.1 Clustering & multi-process
Take advantage of multiple CPU cores by clustering or running multiple instances behind a load balancer.
31.2 CDN & caching
Serve static assets from a CDN, add cache headers, and ship compressed files for faster delivery.
32. Common Gotchas & Tips (v5-aware)
Return or end responses in every branch to avoid hanging requests.
Error-handling middleware must have four arguments (err, req, res, next)
.
Register body parsers before routes that need req.body
.
Avoid sending responses twice or you’ll hit the “Cannot set headers after they are sent” error.
In v5, async errors are forwarded automatically, so you don’t always need wrappers like asyncHandler
.
33. Migration Notes: v4 → v5 (integrated)
- Async errors: v5 auto-forwards rejected promises & thrown errors to error middleware less boilerplate.
- Removed:
req.param(name)
replace withreq.params
/req.query
/req.body
. - Response: don’t use
res.send(status, body)
; useres.status(status).send(body)
. - Test third-party packages for compatibility; many are compatible but check versions.
34. Useful Packages (summary)
- Security:
helmet
,hpp
- CORS:
cors
- Logging:
morgan
,winston
,pino
- Validation:
express-validator
,joi
- File uploads:
multer
- Rate limit:
express-rate-limit
(or Redis-backed) - Swagger:
swagger-ui-express
,swagger-jsdoc
- Session stores:
connect-mongo
,connect-redis
- Realtime:
socket.io
35. Quick HTTP Status & Methods Reference
Common statuses: 200, 201, 204, 301/302, 400, 401, 403, 404, 409, 422, 500. Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.
36. Minimal Production Template (v5-ready)
Here’s a ready-to-use starter that includes recommended middleware and error handling.
import express from 'express';
import helmet from 'helmet';
import compression from 'compression';
import morgan from 'morgan';
import rateLimit from 'express-rate-limit';
const app = express();
app.disable('x-powered-by');
app.use(helmet());
app.use(compression());
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(morgan('combined'));
app.use(rateLimit({ windowMs: 15*60*1000, max: 100 }));
app.get('/healthz', (req, res) => res.sendStatus(200));
// routes here
app.use((req, res) => res.status(404).json({ message: 'Not Found' }));
app.use((err, req, res, next) => {
const status = err.status || 500;
const body = { message: err.message || 'Internal Server Error' };
if (process.env.NODE_ENV !== 'production') body.stack = err.stack;
res.status(status).json(body);
});
export default app;
37. Testing & CI Recommendations
- Use
supertest
andjest
/mocha
for route tests. - Export
app
only; server start in separate file. - Run tests with a fresh test DB or an in-memory DB, clear state between tests.
38. Observability & Metrics
- Export health endpoints (
/healthz
,/readyz
). - Expose Prometheus metrics via instrumentation (e.g.,
prom-client
) for latency/error counts. - Correlate logs with metrics and traces.
39. Accessibility & Developer Experience
- Provide good error messages and status codes for clients.
- Use OpenAPI to document endpoints and schemas.
- Provide sample curl/postman requests in docs.
40. DevTools & Ecosystem
- Postman / Insomnia – test and debug APIs with ease
- nodemon – auto-restart server on file changes
- morgan – HTTP request logger middleware
- winston / pino – advanced logging solutions
- dotenv – manage environment variables securely
41. Deployment
-
Build/Start:
npm run start # or for production NODE_ENV=production node server.js
-
Hosting Platforms:
- Render
- Railway
- Heroku
- AWS EC2 / Lightsail
- DigitalOcean App Platform
-
Env Vars:
.env
,.env.production
for secrets likePORT
,DB_URI
,JWT_SECRET
📚 Resources
- expressjs.com – Official documentation
- MDN – Express Guide
- Awesome Express – curated resources
- Express Middleware List
- Node.js Docs – core modules reference
- Postman Learning Center – API testing guide
Comments
Leave a Comment