· frameworks · 7 min read
10 Hidden Gems in Express.js: Features You Didn't Know Existed
Discover 10 lesser-known Express.js features - from skipping route callbacks to advanced content negotiation - that can simplify your code, improve performance, and make your apps more robust.

Express is small, flexible, and battle-tested - but beneath its familiar routing and middleware surface there are many handy features that often go unnoticed. This article highlights 10 Express.js “hidden gems” with practical examples, use-cases, and caveats so you can start using them today.
References: Express API & Routing docs - https://expressjs.com/
1) next(‘route’) - Skip the rest of the route’s callbacks
When you register multiple handlers for the same route, you can stop processing the remaining handlers for that route by calling next('route')
from within a middleware. This sends control to the next route that matches the request, not the next middleware in the chain.
Example:
// Handlers applied to the same route definition
app.get(
'/user/:id',
function (req, res, next) {
if (req.params.id === '0') {
// skip the rest of handlers for this route and find next matching route
return next('route');
}
// do normal processing
res.send('regular user');
},
function (req, res, next) {
// This won't run for id === '0'
res.send('this is never reached for id 0');
}
);
// This separate route will handle id === '0'
app.get('/user/:id', function (req, res, next) {
res.send('root user');
});
Use-case: conditional route behaviors where one special-case entry should be handled by a distinct handler while other requests follow a different flow.
Caveat: next('route')
only affects handlers attached to the same route definition - not global middleware or route-level middleware attached to different route definitions.
2) res.format - clean content negotiation
res.format
makes it easy to send different representations (JSON, HTML, plain text, etc.) depending on the client’s Accept
header.
app.get('/resource/:id', (req, res) => {
const resource = { id: req.params.id, name: 'Widget' };
res.format({
'text/plain': () => res.send(`${resource.id}: ${resource.name}`),
'text/html': () =>
res.send(`<h1>${resource.name}</h1><p>id: ${resource.id}</p>`),
'application/json': () => res.json(resource),
default: () => res.status(406).send('Not Acceptable'),
});
});
Use-case: APIs that should support both browser-friendly HTML and machine-friendly JSON without writing explicit req.accepts
branching logic.
Reference: https://expressjs.com/
3) app.param / router.param - central param processing and validation
app.param(name, callback)
runs a callback when the specified route parameter is present. Use it to coerce, validate, or preload resources.
// Load user object once for any route containing :userId
app.param('userId', async (req, res, next, id) => {
try {
const user = await db.findUserById(id); // hypothetical DB call
if (!user) return next(new Error('User not found'));
req.user = user; // attach for downstream handlers
next();
} catch (err) {
next(err);
}
});
app.get('/users/:userId/profile', (req, res) => {
res.json(req.user);
});
Tip: app.param
handlers are fired once per request per parameter - great for caching DB lookups.
Caveat: order matters - declare app.param
before the routes that rely on it.
4) req.is and req.accepts helpers
Express provides small helpers to inspect incoming content types and accepted response types.
req.is(types...)
- check request Content-Typereq.accepts(types...)
- check what the client prefersreq.acceptsLanguages()
andreq.acceptsCharsets()
help with localization
app.post('/upload', (req, res) => {
if (!req.is('json')) {
return res.status(415).send('Server expects application/json');
}
// proceed with JSON body handling...
});
app.get('/data', (req, res) => {
const type = req.accepts(['json', 'xml']);
if (type === 'xml') return res.send('<data/>');
res.json({ ok: true });
});
Use-case: graceful content negotiation and type checking without low-level header parsing.
Reference: https://expressjs.com/
5) res.append and res.vary - safer header manipulation and cache signaling
res.append(field, value)
will add a value to an existing header rather than overwrite it. res.vary(field)
flags the Vary header used by caches to indicate the response depends on a request header (e.g., Accept-Encoding
).
app.get('/download', (req, res) => {
// add multiple Link headers
res.append('Link', [
'</styles.css>; rel=preload; as=style',
'</app.js>; rel=preload; as=script',
]);
// signal caches that the response varies by Accept-Encoding
res.vary('Accept-Encoding');
res.send('ok');
});
Use-case: setting multiple values for headers like Set-Cookie
, Link
, or building proper cache behavior.
Reference: https://expressjs.com/
6) Router-level mounting + mergeParams - modular, reusable routers
Express routers let you modularize routes. The mergeParams: true
option is very useful when you mount a router under a path that includes parameters and you need access to those params inside the router.
// parent router
const userRouter = express.Router();
userRouter.get('/profile', (req, res) => {
// need access to userId from parent path
res.send(`user id is ${req.params.userId}`);
});
// mount with mergeParams so child router can see parent params
app.use('/users/:userId', userRouter);
// If userRouter was created with express.Router({ mergeParams: true })
// Better pattern:
const child = express.Router({ mergeParams: true });
child.get('/profile', (req, res) => res.send(req.params.userId));
app.use('/users/:userId', child);
Use-case: build nested route modules (e.g., /users/:userId/posts) where child routes rely on parent parameters.
Caveat: forgetting mergeParams: true
is a common source of undefined
params inside nested routers.
7) Flexible app.use path matching - strings, arrays, and regexes
app.use()
accepts strings, arrays of strings, and regular expressions to match paths. This lets you mount middleware across multiple routes with concise expressions.
// apply middleware to any route that starts with /api or /internal
app.use(['/api', '/internal'], authMiddleware);
// using regex: mount any route that starts with /v1/ or /v2/
app.use(/^\/v[12]\//, versionedMiddleware);
Use-case: cross-cutting middleware (auth, logging) across groups of routes without repeating registrations.
8) Error-handling middleware pattern and async wrappers
Express recognizes error handlers by their 4-argument signature: (err, req, res, next)
. In Express 4.x, asynchronous route handlers that throw or reject Promises are not caught automatically - you should either use a wrapper or rely on Express 5’s built-in promise support in the future.
Async wrapper pattern:
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get(
'/items/:id',
asyncHandler(async (req, res) => {
const item = await db.getItem(req.params.id);
if (!item) return res.status(404).send('Not found');
res.json(item);
})
);
// centralized error handler
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: err.message });
});
Use-case: keep async route handlers concise while ensuring thrown errors hit your centralized error logic.
Reference (Error-handling): https://expressjs.com/
9) res.locals and app.locals - passing data to views and middleware
res.locals
are per-response variables useful when rendering views or passing data between middleware. app.locals
are application-level defaults.
// set a user in an auth middleware
app.use((req, res, next) => {
res.locals.user = req.user || null; // available to templates and later middleware
next();
});
app.get('/dashboard', (req, res) => {
// render template with access to res.locals.user
res.render('dashboard');
});
Use-case: storing request-specific context (current user, CSRF token, feature flags) safely without adding to req
directly.
Caveat: res.locals
is reset for each request; app.locals
persists for the lifetime of the app (useful for app name, default values).
10) trust proxy - correct client IP, protocol, and secure checks behind proxies
If your app runs behind a reverse proxy (Nginx, Heroku, AWS ELB), enabling trust proxy
tells Express to use X-Forwarded-*
headers. This affects req.ip
, req.ips
, req.protocol
, and req.secure
.
// Trust the first proxy in front of the app
app.set('trust proxy', 1);
app.get('/', (req, res) => {
console.log('client ip chain:', req.ips); // e.g., [ '203.0.113.1', '192.0.2.1' ]
console.log('client ip:', req.ip);
console.log('secure?', req.secure);
res.send('ok');
});
Use-case: accurately determine whether a request originated over HTTPS, read the real client IP for logging or rate-limiting, and make security decisions correctly.
Caveat: be conservative - trusting all proxies without thought can be a security risk. Configure trust proxy
appropriately (number of proxies, IP ranges) for your deployment.
Bonus tiny tips (worth knowing):
res.links(obj)
sets Link headers concisely (helpful for pagination).res.location(url)
sets the Location header (useful for 201 responses).app.route(path)
chains handlers for a route in a cleaner way:app.route('/book').get(...).post(...)
.
Final notes
Express packs many small helpers that keep your routing logic clean and expressive. The features above are small but powerful - once you start using a few, your code becomes easier to read, more modular, and more resilient. For API details and deeper reference, check the official Express docs: https://expressjs.com/