· 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.

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-Type
  • req.accepts(types...) - check what the client prefers
  • req.acceptsLanguages() and req.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/

Back to Blog

Related Posts

View All Posts »
5 Hidden Features of AdonisJS You Didn't Know Existed

5 Hidden Features of AdonisJS You Didn't Know Existed

Discover five lesser-known AdonisJS features - route param/model binding, named routes & URL generation, Lucid computed/serialization tricks, advanced validator schema patterns, and streaming-safe file uploads - with practical examples and tips to make your apps faster to build and easier to maintain.