express
Express is the default web framework for Node.js: a thin layer of routing, middleware, and HTTP helpers over the built-in http module. You define routes with app.get and friends, chain middleware functions that each touch the request and pass it on, and render through any of 14+ template engines if you want server-side HTML. It is deliberately unopinionated: no ORM, no validation, no project structure. Version 5 is the current major, on Node 18+, and its headline change is that rejected promises in handlers now flow to your error middleware automatically. At roughly 128 million downloads a week it is still the framework every Node tutorial, Stack Overflow answer, and middleware package assumes.
Still the safe default for a plain Node API or server-rendered app because the ecosystem and collective knowledge around it are unmatched. Pick Fastify for performance-sensitive JSON APIs and Hono for edge runtimes; pick Express when boring and well-documented is the feature.
Use it if
- You want the framework with the most answers: nearly every Node hosting guide, auth tutorial, and middleware package targets Express first
- Your team already knows it and you are hiring; Express experience is the closest thing Node has to a universal baseline
- You depend on Express-specific middleware like Passport strategies, express-session, or multer that has no drop-in equivalent elsewhere
- You want API stability measured in decades; the v4 API held for ten years and v5 keeps the same mental model
- Raw JSON throughput matters; Fastify compiles validation and serialization from route schemas and is measurably faster, while Express does no schema work at all
- You deploy to Cloudflare Workers, Deno, or Bun edge runtimes; Express is Node-first and Hono was built for exactly that portability
- You expect batteries: Express ships no validation, no structured logging, no config, no DI; you assemble and maintain all of that yourself, and middleware quality across npm varies wildly
- You want first-class TypeScript; types live in the separately maintained @types/express package and generic-heavy handler typing is clumsy compared to frameworks designed for TS
- You expect a fast-moving project; Express moves slowly by design and v5 shipped roughly a decade after v4, so features like built-in HTTP/2 support are simply not on the menu
Setup reality
npm install express and you have a server in five lines; Node 18+ required, no config files. The real friction is elsewhere. Migrating v4 code to v5 means route pattern changes from path-to-regexp 8: string regex patterns are gone, wildcards must be named (/*splat instead of *), and optional params use braces. The mountains of v4-era tutorials online silently teach patterns that now throw. TypeScript users install @types/express separately and it occasionally lags releases. Middleware ordering bugs fail silently: register express.json() after your routes and req.body is just undefined. Error handlers are detected by having exactly four arguments, which trips people up constantly.
Patterns
Minimal HTTP serverbasic-server
import express from 'express'
const app = express()
app.get('/', (req, res) => {
res.send('Hello World')
})
app.listen(3000, () => {
console.log('listening on http://localhost:3000')
})Express 5 requires Node 18+; ESM imports work out of the box, no transpiler needed.
Parse JSON request bodiesjson-body
app.use(express.json())
app.post('/api/items', (req, res) => {
const { name } = req.body
res.status(201).json({ name })
})body-parser is built in since 4.16; default body limit is 100kb, raise it with express.json({ limit: '1mb' }). Register before your routes or req.body is undefined.
Read route and query parametersroute-params
app.get('/users/:id', (req, res) => {
const { id } = req.params
const { fields } = req.query
res.json({ id, fields })
})req.param() was removed in v5; always use req.params, req.query, or req.body explicitly.
Split routes into modules with Routermodular-router
// routes/users.js
import { Router } from 'express'
const router = Router()
router.get('/', (req, res) => res.json([]))
router.get('/:id', (req, res) => res.json({ id: req.params.id }))
export default router
// app.js
app.use('/users', router)Paths inside the router are relative to the mount point, so router.get('/:id') serves /users/:id.
Write and apply middlewarecustom-middleware
function requestLogger(req, res, next) {
console.log(`${req.method} ${req.originalUrl}`)
next()
}
app.use(requestLogger)Forgetting next() hangs the request forever with no error; middleware runs strictly in registration order.
Async handlers without try/catch wrappersasync-errors
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id) // if this rejects,
res.json(user) // Express 5 forwards the error to error middleware
})This is the big v5 win: rejected promises reach your error handler automatically. In v4 the same code crashed or hung, which is why asyncHandler wrappers existed.
Central error-handling middlewareerror-handler
app.use((err, req, res, next) => {
console.error(err)
const status = err.status || 500
res.status(status).json({ error: err.message })
})Express identifies error middleware by its four-argument signature; drop next from the params and it silently becomes normal middleware. Register it last.
404 handler for unmatched routescatch-all-404
// after all other routes
app.use((req, res) => {
res.status(404).json({ error: 'not found' })
})If you need a path pattern instead, v5 wildcards must be named: app.all('/*splat', ...) replaces the bare '*' from v4.
Serve static filesstatic-files
import path from 'node:path'
app.use(express.static(path.join(import.meta.dirname, 'public'), {
maxAge: '1d',
index: 'index.html'
}))Requests fall through to later routes on miss, so mount static before the 404 handler but after any API routes that share paths.
Enable CORS for an APIcors
import cors from 'cors'
app.use(cors({
origin: 'https://app.example.com',
credentials: true
}))cors is a separate official package (npm i cors); with credentials: true you cannot use origin: '*', browsers will reject it.
Redirects and explicit status codesredirects-and-status
app.get('/old-path', (req, res) => {
res.redirect(301, '/new-path')
})
app.post('/items', (req, res) => {
res.status(201).json({ ok: true })
})res.redirect defaults to 302; pass 301 explicitly for permanent moves or search engines keep hitting the old URL.
Shut down cleanly on SIGTERMgraceful-shutdown
const server = app.listen(3000)
process.on('SIGTERM', () => {
server.close(() => {
// close DB pools etc, then exit
process.exit(0)
})
})app.listen returns a plain Node http.Server; without close() in-flight requests are cut off on deploys.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| fastify | npm | JSON API where throughput matters and you want schema-based validation built in |
| hono | npm | You deploy to edge runtimes or want one codebase across Node, Bun, Deno, and Workers |
| koa | npm | You want a smaller async-first core from the same lineage and will pick every middleware yourself |