Node.js gives you a server runtime. It doesn't give you routing, middleware, or any opinion on how to structure an HTTP application. Express.js does.
Express.js is a minimal, unopinionated web framework for Node.js that provides the core abstractions (routing, middleware, and request/response handling) needed to build web servers and APIs without the boilerplate of raw http.createServer(). Established in 2010, Express remains one of the most-used Node.js web frameworks on npm, and a mainstay of JavaScript frameworks for backend work.
The landscape is shifting, though. Express 5 shipped in late 2024 after a decade on v4, and the framework now has to meet higher expectations around async handling, routing safety, and modern Node.js support. Still, its middleware ecosystem, learning curve, and sheer ubiquity make it the starting point for most Node.js backend development. This article explains how Express works, what it gives you, how it pairs with a headless CMS, and where it fits in 2026.
In brief:
- Express.js is a fast, unopinionated, minimalist web framework for Node.js that provides routing, middleware chaining, and request/response utilities for building web servers and APIs.
- Express 5.0.0 was released in September 2024, with Express 5.1.0 becoming the first v5 release tagged
lateston npm in March 2025; it introduces native async error handling and drops support for Node.js versions below 18. - Express's middleware ecosystem, including Passport, Helmet, Morgan, Multer, and thousands more, remains its deepest practical advantage.
- Express handles the HTTP layer but not content, which is why most production teams pair it with a headless CMS like Strapi that supplies the content model, Admin Panel, and generated APIs.
What Is Express.js?
At its core, Express is a thin layer on top of Node.js's built-in http module. It adds routing, middleware chaining, request/response utility methods, and template engine integration. Express doesn't impose a project structure, ORM choice, or authentication strategy. You compose what you need from its middleware ecosystem. This unopinionated philosophy is both its greatest strength (flexibility) and its most common criticism (no guardrails for large apps).
Express is an OpenJS Foundation project, not owned by a single company. It's governed by a Technical Committee and released under the MIT license.
Current version state: v5.1 shipped in March 2025, with v4 still supported. Express 5 requires Node.js 18 or later, adds native async error handling, and modernizes routing. It's the first major version in a decade.
Core Express Concepts
These four building blocks form the backbone of most Express.js work. If you understand them, you'll cover most of what you handle day to day.
Routing
Express maps HTTP methods and URL paths to handler functions. app.get('/users/:id', handler) matches GET requests to /users/42 and extracts id as req.params.id. Route parameters, query strings (req.query), and wildcard patterns handle the common path shapes you'll use when you build a REST API endpoint.
Express 5 updates the routing engine to use path-to-regexp@8.x. Regexp characters are no longer supported in route paths, and wildcard syntax changed: bare * no longer works and must be named, for example, /*splat or /{*splat}.
Route grouping via express.Router() lets you organize related endpoints into modular files. This becomes important as soon as your app grows beyond a single-file prototype:
// routes/users.js
const router = express.Router();
router.get('/', getAllUsers);
router.get('/:id', getUserById);
app.use('/api/users', router);Middleware
Middleware is the core abstraction in Express. Every function with the signature (req, res, next) is middleware. Functions execute in the order they're registered, and each one can modify the request, modify the response, end the request-response cycle, or call next() to pass control forward.
This linear pipeline model is what makes Express composable. Authentication, logging, body parsing, CORS, compression, and rate limiting can each be mounted with app.use(). If you've worked with route-based middleware in a headless CMS context, the mental model is the same.
// index.js
// Simple request-logging middleware
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next(); // pass control to the next middleware
});
// Then route handlers run after logging
app.get('/api/users', (req, res) => {
res.json({ users: [] });
});The ecosystem scale here is one of Express's biggest strengths: thousands of battle-tested middleware packages, including Passport for authentication, Helmet for security headers, Morgan for request logging, and Multer for file uploads.
Request and Response Objects
Express extends Node's native req and res with utility methods. For input: req.body (via body-parser middleware, now built into Express as express.json()), req.params, req.query, req.headers. For output: res.json(), res.send(), res.status(), res.redirect(), and res.render() for template engine integration.
These convenience methods eliminate the low-level stream handling you'd otherwise write with raw Node.js:
// routes/posts.js
app.post('/api/posts', (req, res) => {
const { title, body } = req.body;
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const post = { id: Date.now(), title, body };
res.status(201).json(post);
});Error Handling
Express uses a special four-argument middleware signature (err, req, res, next) for centralized error handling. In Express 4, async errors required manual try/catch plus next(err) in every handler. Express 5 fixes this: rejected promises in middleware and route handlers are automatically caught and forwarded to the error handler, as documented in the v5 API.
This is one of the most noticeable developer experience improvements in Express 5:
// routes/users.js
// Express 4: verbose
app.get('/user/:id', async (req, res, next) => {
try {
const user = await getUser(req.params.id);
res.send(user);
} catch (err) {
next(err);
}
});
// Express 5: clean
app.get('/user/:id', async (req, res) => {
const user = await getUser(req.params.id); // rejection auto-forwarded
res.send(user);
});Express recommends defining custom error-handling middleware last, after other middleware and routes, but also provides a default error handler at the end of the middleware stack if you don't define one. The four-parameter signature (err, req, res, next) is required. Express identifies error handlers by their argument count:
// index.js
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: err.message });
});Express 5: What Changed After a Decade
The decade-long version gap shapes any Express.js discussion in 2026. Express 5.0.0 shipped with formal release notes, and Express 5.1.0 became the first v5 release tagged latest on npm on March 31, 2025, moving v4 into maintenance mode.
Key changes:
- Native async/await error handling: rejected promises auto-forward to error middleware, eliminating
try/catchboilerplate. - Node.js below 18 dropped: this enables modern language features and removes legacy polyfills.
- New routing engine:
path-to-regexp@8.xintroduces new protections and semantic changes to reduce some ReDoS risks, though several early 8.x versions still contained ReDoS vulnerabilities that were only fully patched in later 8.x releases. Wildcard syntax changed fromapp.get('/*')toapp.get('/{*splat}'). Optional parameters changed from/:param?to{/:param}. - Regex removed from route strings: character classes like
[discussion|page]no longer work in path strings. Use arrays of paths instead. - Deprecated APIs removed:
app.del(),req.param(),res.send(status)as numeric, and others are no longer supported in Express 5 and must be updated. - Body parser changes:
urlencodeddefaultsextendedtofalse, andreq.bodyisundefined(not{}) when no body is sent. app.routerreintroduced as a reference to the base router instance.
Migration reality: The Express team published an official codemod that automates many API renames:
npx codemod@latest @expressjs/v5-migration-recipeThe codemod handles method renames, argument order fixes, and deprecated API replacements. However, route path syntax changes (wildcards, optional params, and regex removal) can't be automated and require manual review. That's where regressions are most likely to show up.
The Express team published an LTS timeline. Express v4 remains widely used and continues to receive maintenance updates, but new projects should generally start on v5.
Building a REST API with Express
If you want to validate the concepts quickly, a small REST API is enough. The approach also translates if you're coming from the MERN stack, where Express plays the server role.
Project Setup
mkdir my-api && cd my-api && npm init -y && npm install expressCreate index.js. Four lines get you to a running HTTP server:
// index.js
const express = require('express');
const app = express();
app.get('/', (req, res) => res.json({ status: 'running' }));
app.listen(3000, () => console.log('Listening on port 3000'));TypeScript Configuration
For TypeScript projects, install @types/express and use tsx as the runner:
npm install --save-dev typescript @types/express tsxThe @types/express package is community-maintained via DefinitelyTyped, not published by the Express team itself. The type definitions are functional and widely used, though Express 5 still relies on these community types. If you're weighing whether the extra setup is worth it, the benefits of TypeScript are most visible in middleware stacks where req and res flow through many layers.
Defining Routes and Controllers
Build a CRUD API for a posts resource using express.Router() to isolate routes in a separate file:
// routes/posts.js
const express = require('express');
const router = express.Router();
let posts = [];
router.get('/', async (req, res) => {
res.json(posts);
});
router.get('/:id', async (req, res) => {
const post = posts.find(p => p.id === req.params.id);
if (!post) return res.status(404).json({ error: 'Not found' });
res.json(post);
});
router.post('/', async (req, res) => {
const post = { id: String(Date.now()), ...req.body };
posts.push(post);
res.status(201).json(post);
});
router.put('/:id', async (req, res) => {
const index = posts.findIndex(p => p.id === req.params.id);
if (index === -1) return res.status(404).json({ error: 'Not found' });
posts[index] = { ...posts[index], ...req.body };
res.json(posts[index]);
});
router.delete('/:id', async (req, res) => {
posts = posts.filter(p => p.id !== req.params.id);
res.status(204).send();
});
module.exports = router;Mount it in your main app with app.use('/api/posts', postsRouter). This pattern (router modules mounted on path prefixes) is how real Express apps scale beyond a single file.
Adding Middleware: Auth, Validation, Logging
Layer practical middleware onto the CRUD API. Middleware order matters. If you're adding real authentication rather than a token check, the mechanics of API authorization are worth understanding before you wire up Passport or JWT strategies, and the patterns in protected routes with Node.js translate directly to Express middleware.
// index.js
const express = require('express');
const morgan = require('morgan');
const postsRouter = require('./routes/posts');
const app = express();
// 1. Logging: runs first, captures every request
app.use(morgan('dev'));
// 2. Body parsing: must precede any req.body reads
app.use(express.json());
// 3. Auth middleware: gates protected routes
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token || token !== process.env.API_TOKEN) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
};
// 4. Validation middleware: checks required fields
const validatePost = (req, res, next) => {
if (!req.body.title) {
return res.status(400).json({ error: 'Title is required' });
}
next();
};
// Mount router with auth; validation runs only on POST/PUT
postsRouter.post('/', validatePost);
postsRouter.put('/:id', validatePost);
app.use('/api/posts', authMiddleware, postsRouter);
// 5. Error handler: must be last
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: err.message });
});
app.listen(3000);Logging first catches all requests, then body parsing, then auth, then validation on matched routes, then the handler.
Structuring for Production
Moving from a tutorial to a production Express application usually means dealing with the parts teams learn the hard way:
- Environment-based configuration matters. Use
dotenvor the Node.js 21+--env-fileflag to manage secrets and environment-specific settings like database URLs and API keys without hardcoding them. - Graceful shutdown handling prevents dropped requests during deployments. Listen for
SIGTERMandSIGINT, stop accepting new connections, and let in-flight requests complete before exit. - In production, Express apps typically run behind a reverse proxy such as nginx or a cloud load balancer. The reverse proxy handles TLS termination, static asset serving, and request buffering, letting Express focus on application logic.
- Set
app.set('trust proxy', 1)when running behind a proxy so thatreq.ipandreq.protocolreflect the client's actual values rather than the proxy's. - Audit your middleware stack for the usual suspects: rate limiting, input validation, secure headers via Helmet, and structured logging. The broader API security best practices checklist covers most of what production Express apps miss.
These five areas cover the gap between a working prototype and something you can leave running.
Integrate Express with Strapi
Express solves the HTTP layer. It says nothing about where your content lives, who edits it, or how non-developers publish changes. That gap is where most Express projects quietly accumulate a hand-rolled admin interface, a bespoke permissions table, and a media upload endpoint nobody wants to maintain.
Strapi is an open-source headless CMS built on Node.js that fills that gap. It generates REST and GraphQL endpoints from your content model, ships an Admin Panel for editors, and handles media, roles, and localization out of the box. Strapi runs on Koa rather than Express, so you don't mount one inside the other. Instead, they sit side by side: Strapi owns content, Express owns your custom business logic. If you want the internals, Strapi's request flow walks through how the Koa-based middleware stack processes a request.
There are two integration shapes worth knowing.
Set Up Strapi as the Content Layer
Scaffold a Strapi project alongside your Express service:
npx create-strapi@latest my-content-backendStrapi runs on port 1337 by default, with SQLite locally and PostgreSQL or MySQL in production. Inside the Admin Panel, use the Content-Type Builder to define a Collection Type (an article with title, slug, body, and a cover media field, for instance), then generate a read-only API token under Settings. Your Express service uses that token to authenticate its requests.
The important detail for anyone coming from Strapi v4: Strapi 5 returns flat entries with a documentId, not the nested data.attributes shape. Write your response mapping against the flat format.
Fetch Strapi Content from an Express Route
Your Express app becomes a backend-for-frontend layer. It calls Strapi's Content API, reshapes the payload, and adds whatever logic Strapi shouldn't own (pricing calculations, third-party lookups, personalization):
// routes/content.js
const express = require('express');
const router = express.Router();
const STRAPI_URL = process.env.STRAPI_URL;
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
router.get('/articles', async (req, res) => {
const response = await fetch(
`${STRAPI_URL}/api/articles?populate=cover&pagination[pageSize]=10`,
{ headers: { Authorization: `Bearer ${STRAPI_TOKEN}` } }
);
if (!response.ok) {
return res.status(502).json({ error: 'Content service unavailable' });
}
// Strapi 5 returns flat entries keyed by documentId
const { data, meta } = await response.json();
res.json({
articles: data.map(article => ({
id: article.documentId,
title: article.title,
slug: article.slug,
coverUrl: article.cover?.url ?? null,
})),
pagination: meta.pagination,
});
});
module.exports = router;Notice there's no try/catch here. Express 5 forwards the rejected promise to your error handler automatically, which is exactly the kind of boilerplate reduction the v5 upgrade buys you.
If you need Strapi itself to expose something its generated routes don't cover, you can add a custom API endpoint rather than proxying and reshaping in Express.
Handle Strapi Webhooks in Express
Polling Strapi on every request wastes both services. Configure a webhook in the Strapi Admin Panel under Settings, point it at your Express service, and invalidate caches only when content actually changes:
// routes/strapi-webhooks.js
const express = require('express');
const router = express.Router();
router.post('/content-updated', express.json(), (req, res) => {
if (req.headers.authorization !== process.env.STRAPI_WEBHOOK_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { event, model, entry } = req.body;
if (event === 'entry.publish' && model === 'article') {
cache.delete(`article:${entry.documentId}`);
}
// Respond fast; do heavy work asynchronously
res.status(204).send();
});
module.exports = router;Strapi sends the event name, the model, and the affected entry. Respond quickly and queue anything slow, because Strapi treats a slow webhook the same way any HTTP client would.
Where Strapi's Middleware Fits
Strapi has its own middleware stack, and the mental model transfers cleanly from Express. A Strapi middleware is a factory that returns a Koa handler:
// src/middlewares/request-timer.js
module.exports = (config, { strapi }) => {
return async (ctx, next) => {
const start = Date.now();
await next();
strapi.log.info(`${ctx.method} ${ctx.url} took ${Date.now() - start}ms`);
};
};Register it in the middleware array, where order matters the same way it does in Express:
// config/middlewares.js
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
'global::request-timer',
];Two differences to keep in mind. Strapi uses a single ctx object instead of separate req and res arguments, and ctx.body sets the response rather than reading the request. Beyond that, if you can reason about an Express middleware chain, you can reason about Strapi's.
For permissions, Strapi's role-based system usually replaces whatever auth middleware you were about to write. The authentication and authorization model covers public roles, authenticated users, and API tokens, so your Express layer only enforces the rules specific to your own business logic.
When Express Is Still the Right Choice
Express remains the right framework in several common scenarios, and the reasons go beyond inertia.
For prototyping and learning, Express benefits from a huge pool of learning resources in the Node.js ecosystem. Most Node.js backend tutorials teach Express first, which means you can usually ramp up quickly and find debugging help without much friction.
From a hiring perspective, many backend JavaScript developers already know Express. Teams that need to onboard engineers quickly or staff up with contractors benefit from that shared baseline knowledge. The framework's concepts (middleware chaining, req/res, and Router) are also broadly transferable.
Middleware lock-in is a real consideration. Migrating Passport authentication strategies, Helmet security configurations, or custom middleware built over years of production use has tangible engineering cost. If your application depends on specific Express middleware, the migration burden may outweigh the benefits of switching.
Where Express stops being enough is content. The moment a marketing team needs to publish without a deploy, or you need draft previews, localized entries, and media handling, you're building a CMS inside your Express app. That's the point to reach for a purpose-built content backend instead.
Wrapping Up
Express is not the fastest Node.js framework, and it's no longer the most modern. What it remains is one of the most understood, the most supported, and the most composed-upon backend frameworks in the JavaScript ecosystem. Express 5 brings improved async error handling, and its middleware library covers virtually every backend concern.
The pairing worth recommending is Express plus Strapi. Let Strapi own the content model, Admin Panel, media, roles, and generated REST or GraphQL APIs, then keep Express for the custom routes, integrations, and business logic that belong to your application. You skip building an admin interface, and your editors stop filing tickets to change a headline. The MDN Local Library rebuild shows how much of a typical Express data layer Strapi replaces outright, and it's a useful gut check on scope before you commit either way.
If you want to see the split in practice, spin up a project on Strapi Cloud and point an Express route at its Content API. Ten minutes is usually enough to tell whether the division of labor fits your stack.




