Extending the Express API
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum
for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the
official documentation and the package repositories on 2026-10-29. Express is deliberately small, and its request and
response objects are ordinary Node.js objects whose prototype chain has been extended with a few dozen properties and
methods. The same mechanism is open to application code: a getter such as req.isMobile or a helper such as
res.ok(data) can be added once and then used in every handler. The page explains how the prototypes are wired, how to
add and override members safely, how to store custom settings, which lighter alternatives exist, how to describe the
additions to TypeScript, and why most projects should reach for a plain middleware or helper function before touching
the prototypes. The examples use ESM and the node: prefix for built-in modules, belong to the Bookshelf API that
grows across this section, and contain placeholders only, never real secrets. For the members that already exist see
Request and Response.
How the prototypes are wired
Every incoming request is an http.IncomingMessage created by Node.js, and every response is an
http.ServerResponse. Express does not wrap them. Instead, the first layer of each application, the built-in
initialisation middleware, changes the prototype of both objects so that they inherit from Express-specific prototypes:
req -> app.request -> express.request -> http.IncomingMessage.prototype
res -> app.response -> express.response -> http.ServerResponse.prototype
Three objects take part, and the difference between them is the key to extending the API:
| Object | Role |
|---|---|
|
The shared prototypes that carry the Express members ( |
|
Per-application objects created with |
|
The Node.js objects of one request. Their prototype is set to |
When an application is mounted inside another one with app.use('/admin', adminApp), requests handled by the mounted
application temporarily use its own app.request and app.response, and the parent’s prototypes are restored when the
request leaves it. A getter added to the parent therefore does not exist inside the mounted application unless it is
added to the mounted application as well, or to the shared express.request.
Where to add a member
The official guide on overriding the API describes the two levels. The choice follows from who must see the change:
-
app.requestandapp.responsefor a member that belongs to one application. This is the right default: it stays invisible to other applications in the same process, which matters in test suites that create many applications and in modular monoliths that mount several. -
express.requestandexpress.responsefor a member that every application in the process must have, typically when a shared library installs it once. It is a global side effect, so it belongs in a well-named package and never in application code that can be loaded twice. -
A property assigned on
reqorresinside a middleware, for a value that exists for one request only (the current user, a request identifier). That is not an extension of the API but ordinary per-request state, covered in Lighter alternatives: res.locals, app.locals and plain middleware.
Adding getters and helper methods
A computed property is best defined as an accessor on the prototype with Object.defineProperty. Express defines its own
req.ip, req.hostname, req.secure and req.xhr the same way. The accessor runs on every read and sees the current
request through this:
// src/express-extensions.js
import express from 'express';
const MOBILE_PATTERN = /Mobi|Android|iPhone|iPad/i;
/**
* Install the Bookshelf extensions on one application.
* @param {import('express').Application} app
*/
export function extendBookshelfApi(app) {
// req.isMobile: a boolean computed from the User-Agent header.
Object.defineProperty(app.request, 'isMobile', {
configurable: true,
enumerable: false,
get() {
// `this` is the current request, so a regular function is required here.
return MOBILE_PATTERN.test(this.get('user-agent') ?? '');
},
});
// req.requestedPageSize: the validated ?pageSize= value, capped at 100.
Object.defineProperty(app.request, 'requestedPageSize', {
configurable: true,
enumerable: false,
get() {
const raw = Number.parseInt(String(this.query.pageSize ?? ''), 10);
if (!Number.isInteger(raw) || raw < 1) return 20;
return Math.min(raw, 100);
},
});
// res.ok(data) and res.created(data, location): response helpers.
app.response.ok = function ok(data) {
return this.status(200).json({ data });
};
app.response.created = function created(data, location) {
if (location) this.location(location);
return this.status(201).json({ data });
};
}
// src/server.js
import express from 'express';
import { extendBookshelfApi } from './express-extensions.js';
const app = express();
extendBookshelfApi(app);
const books = [{ id: 1, title: 'Dune' }];
app.get('/books', (req, res) => {
const size = req.requestedPageSize;
res.ok({ items: books.slice(0, size), mobile: req.isMobile });
});
app.post('/books', express.json(), (req, res) => {
const book = { id: books.length + 1, title: String(req.body?.title ?? 'Untitled') };
books.push(book);
res.created(book, `/books/${book.id}`);
});
app.listen(3000, () => {
console.log('Bookshelf API listening on port 3000');
});
Four details of the example are deliberate:
-
The accessors and helpers are regular functions, never arrow functions. An arrow function does not receive the request as
this, sothis.getwould fail or read the module-levelthis, which isundefinedin ESM. -
enumerable: falsekeeps the getter out ofObject.keys(req)and out of logging libraries that dump an object. Plain assignment such asapp.request.isMobile = ...would create an enumerable data property that holds one value for every request, which is almost never what is wanted for a computed value. -
configurable: trueallows the property to be redefined, which a test or a second call to the installer needs. A property defined without it is permanent for the life of the process, and defining it again throws aTypeError. -
The helpers return the result of
json(), so handlers can writereturn res.ok(data)and keep the same early-return style as with the built-in methods.
Overriding a built-in method
The same assignment replaces an existing method. The guide shows app.response.sendStatus being replaced, and the
technique applies to any member of the response prototype. The safest pattern keeps a reference to the original
implementation and delegates to it, so that the override only adds behaviour:
// src/response-envelope.js
import express from 'express';
const originalJson = express.response.json;
/**
* Wrap every res.json() of one application in a { data, meta } envelope,
* unless the body is already an error or already an envelope.
* @param {import('express').Application} app
*/
export function wrapJsonInEnvelope(app) {
app.response.json = function json(body) {
const alreadyShaped =
body !== null &&
typeof body === 'object' &&
('data' in body || 'type' in body || 'errors' in body);
const payload = alreadyShaped
? body
: { data: body, meta: { generatedAt: new Date().toISOString() } };
// Delegate to the original so that ETag, Content-Type, JSON spaces,
// the replacer and the escaping settings all keep working.
return originalJson.call(this, payload);
};
}
// src/server.js (excerpt)
import express from 'express';
import { wrapJsonInEnvelope } from './response-envelope.js';
const app = express();
wrapJsonInEnvelope(app);
app.get('/books/:id', (req, res) => {
res.json({ id: Number(req.params.id), title: 'Dune' });
});
Overriding built-ins needs more care than adding new names, and several rules follow from how Express is implemented:
-
Keep the original and call it with
.call(this, ...). Copying behaviour by hand loses the detailsres.jsontakes care of: thejson replacer,json spacesandjson escapesettings, the defaultContent-Type, and theETagthatres.sendcomputes. -
Know the internal calls.
res.json()ends by callingthis.send(),res.jsonp()callsthis.send()as well, andres.send()with an object body callsthis.json(). Overriding bothsendandjsontherefore needs a guard against running the wrapper twice or against recursion, which is why the example above overrides onlyjson. -
Keep the signature compatible. Third-party middleware such as
compression,morganandexpress-sessionpatchres.endandres.writeHeadand assume that the rest of the API behaves as documented. A helper that returns a different type or ignores an argument breaks them in ways that are hard to trace. -
Install the override before any route, and only once. Overriding the same method twice in two places creates a chain that nobody can read, and installing it twice on
express.responsewraps the wrapper. -
An override of a core method is a global rule for the whole application. If only some routes need the behaviour, a middleware that wraps
res.jsonfor the current response only (see Lighter alternatives: res.locals, app.locals and plain middleware) is easier to reason about.
Replacing the prototype of one request
Occasionally an application needs a different set of members for a subset of routes. Because the request and response
are plain objects, a middleware can point them at another prototype with Object.setPrototypeOf. That prototype should
inherit from the application’s own, so that every Express member keeps working:
// src/admin-prototypes.js
import { Router } from 'express';
/**
* A router whose requests and responses carry extra admin-only members.
* @param {import('express').Application} app
*/
export function createAdminRouter(app) {
const adminRequest = Object.create(app.request, {
isAdmin: {
configurable: true,
get() {
return this.get('x-admin-token') === 'replace-with-a-real-check';
},
},
});
const adminResponse = Object.create(app.response, {
audit: {
configurable: true,
value: function audit(action) {
this.set('x-audit-action', action);
return this;
},
},
});
const router = Router();
router.use((req, res, next) => {
Object.setPrototypeOf(req, adminRequest);
Object.setPrototypeOf(res, adminResponse);
next();
});
router.delete('/books/:id', (req, res) => {
if (!req.isAdmin) return res.status(403).json({ error: 'forbidden' });
return res.audit('book.delete').status(204).end();
});
return router;
}
Changing a prototype at run time makes the engine discard optimisations for the object, so it is a technique for isolated cases and not for every request of a hot path. The header check in the example is a placeholder that stands for a real authentication check, which belongs in Authentication & Authorization. Prototype switching also leaks beyond the router: once a middleware has changed the prototype, every later layer for that request sees the new one, including layers outside the router that the request returns to.
Custom application settings
Express keeps its configuration in app.settings, an ordinary object that is read and written through methods. The same
store accepts any name, so an application can keep its own configuration next to Express’s built-in settings such as
trust proxy, etag, view engine and query parser:
| Method | Behaviour |
|---|---|
|
Stores |
|
With a single argument, returns the stored value. With a path and handlers it registers a GET route, so a setting whose name looks like a path is a bug waiting to happen. |
|
Shortcuts for |
|
Return |
// src/settings.js
import process from 'node:process';
/**
* Store the Bookshelf configuration in the application settings.
* @param {import('express').Application} app
*/
export function configureSettings(app) {
app.set('bookshelf.name', 'Bookshelf API');
app.set('bookshelf.maxPageSize', Number(process.env.MAX_PAGE_SIZE ?? 100));
app.set('bookshelf.catalogue', new Map());
// Feature flags read like Express's own booleans.
if (process.env.ENABLE_EXPERIMENTAL_SEARCH === 'true') {
app.enable('bookshelf.experimentalSearch');
} else {
app.disable('bookshelf.experimentalSearch');
}
// Built-in settings sit in the same store.
app.disable('x-powered-by');
app.set('json spaces', process.env.NODE_ENV === 'production' ? 0 : 2);
}
// src/server.js (excerpt)
import express from 'express';
import { configureSettings } from './settings.js';
const app = express();
configureSettings(app);
app.get('/search', (req, res) => {
if (!req.app.enabled('bookshelf.experimentalSearch')) {
return res.status(404).json({ error: 'search is not available' });
}
const limit = Math.min(Number(req.query.limit ?? 20), req.app.get('bookshelf.maxPageSize'));
return res.json({ limit });
});
Source: Express API: Application
Guidelines for custom settings:
-
Use a namespace prefix such as
bookshelf.for every custom name. Express reserves a growing list of names (case sensitive routing,env,etag,jsonp callback name,json escape,json replacer,json spaces,query parser,strict routing,subdomain offset,trust proxy,views,view cache,view engine,x-powered-by), and a later Express release may add a name that collides with an unprefixed one. -
Read settings through
req.appinside handlers so that the code works unchanged when the router is mounted in a different application, for instance in a test. -
Treat settings as configuration set once at start-up. Mutating them while requests are in flight is allowed, but it is global state, and the order of writes across concurrent requests is not defined.
-
Do not store secrets or per-user data in settings. They are reachable from
req.app.settings, which logging middleware and error pages sometimes serialise.
Settings and mounted applications
When app.use('/admin', adminApp) runs, the mount event makes the parent’s settings the prototype of the child’s.
Reading a name that the child never set returns the parent’s value, while writing the same name in the child shadows it
without changing the parent. Settings that have a default value (etag, x-powered-by, env, query parser,
subdomain offset, views and the rest) are already own properties of the child, so they are not inherited. Settings
with no default (view engine, json spaces, json replacer, and custom names such as bookshelf.name) are
inherited. There are two exceptions: trust proxy is inherited although it has a default, and view cache is not
inherited in production. A parent that sets trust proxy after mounting therefore still influences a child that never
set it, and a child that sets its own view engine does not leak it upwards. Express also fires
app.on('mount', callback) on the child, which is the hook for code that needs to read a parent’s setting at the
moment of mounting:
// src/admin-app.js
import express from 'express';
export function createAdminApp() {
const admin = express();
admin.on('mount', (parent) => {
// parent is the application that mounted this one.
console.log('Admin mounted in', parent.get('bookshelf.name'));
});
admin.get('/', (req, res) => {
res.json({ name: req.app.get('bookshelf.name'), mountpath: req.app.mountpath });
});
return admin;
}
Source: Express API: Application
Lighter alternatives: res.locals, app.locals and plain middleware
Extending a prototype is the most invasive option. Express offers lighter places for the same information, and the official guidance for most needs points to them:
| Need | Better tool | Why |
|---|---|---|
A value that lives for one request (user, request id, parsed context) |
|
Nothing global changes, and the value disappears with the request. |
A value shared by every request and every template (site name, helpers) |
|
An object that Express merges into every rendered view. Its name is |
A computed value used in many handlers |
A function in a module, called as |
Importable, testable, searchable and type checked without any augmentation. |
A response format used in many handlers |
A helper function, or a middleware that attaches a function to |
The behaviour is visible where it is installed and can be scoped to a router. |
Cross-cutting behaviour (envelope, timing headers) |
A middleware that wraps |
Scoped, ordered and removable, with no prototype change. |
// src/request-context.js
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
export const requestContext = new AsyncLocalStorage();
/**
* Attach per-request state without touching any prototype.
*/
export function requestContextMiddleware(req, res, next) {
const requestId = req.get('x-request-id') ?? randomUUID();
res.set('x-request-id', requestId);
res.locals.requestId = requestId;
// A per-response helper: only this response gets it, and only after this middleware.
res.locals.ok = (data) => res.status(200).json({ data, requestId });
requestContext.run({ requestId }, next);
}
/** Read the request id from any function called during the request. */
export function currentRequestId() {
return requestContext.getStore()?.requestId;
}
// src/server.js (excerpt)
import express from 'express';
import { requestContextMiddleware, currentRequestId } from './request-context.js';
const app = express();
app.locals.siteName = 'Bookshelf';
app.use(requestContextMiddleware);
app.get('/books/:id', (req, res) => {
console.log('Serving book for request', currentRequestId());
res.locals.ok({ id: Number(req.params.id) });
});
Source: Express API: res.locals
res.locals is a plain object that is created empty for every response and is available to the views rendered during
that request. app.locals is the application-wide equivalent, and both are merged into the data a view receives, with
res.locals taking precedence over app.locals and the explicit render() argument taking precedence over both.
Neither is a security boundary: anything stored there is visible to every template, so never put secrets in them.
Decision guide:
-
If a function can do the job, write a function.
-
If the value belongs to one request, use
res.localsorAsyncLocalStorage. -
If the behaviour belongs to one router, use a middleware mounted on that router.
-
If the member must feel native on
reqorresfor the whole application, extendapp.requestorapp.response. -
If the member must exist in every application of the process, extend
express.requestorexpress.response, and only from a library.
TypeScript declaration merging
Adding a member at run time does not tell the compiler about it, and req.isMobile fails to type check until the
interfaces are merged. The types come from @types/express (the 5.x line for Express 5), which re-exports the
interfaces of @types/express-serve-static-core. Both packages expose a global Express namespace with empty
interfaces that exist to be augmented: Express.Request, Express.Response, Express.Locals and Express.Application.
// src/types/express-extensions.d.ts
// The file must be a module (it has an import or export) for `declare global` to work.
export {};
declare global {
namespace Express {
interface Request {
/** True when the User-Agent looks like a mobile browser. */
readonly isMobile: boolean;
/** The validated pageSize query value, between 1 and 100. */
readonly requestedPageSize: number;
}
interface Response {
ok(data: unknown): this;
created(data: unknown, location?: string): this;
}
// Typed res.locals and app.locals.
interface Locals {
requestId?: string;
siteName?: string;
}
}
}
// src/express-extensions.ts
import type { Application, Request, Response } from 'express';
const MOBILE_PATTERN = /Mobi|Android|iPhone|iPad/i;
export function extendBookshelfApi(app: Application): void {
Object.defineProperty(app.request, 'isMobile', {
configurable: true,
get(this: Request): boolean {
return MOBILE_PATTERN.test(this.get('user-agent') ?? '');
},
});
Object.defineProperty(app.request, 'requestedPageSize', {
configurable: true,
get(this: Request): number {
const raw = Number.parseInt(String(this.query.pageSize ?? ''), 10);
return Number.isInteger(raw) && raw > 0 ? Math.min(raw, 100) : 20;
},
});
app.response.ok = function ok(this: Response, data: unknown) {
return this.status(200).json({ data });
};
app.response.created = function created(this: Response, data: unknown, location?: string) {
if (location) this.location(location);
return this.status(201).json({ data });
};
}
// src/server.ts
import express from 'express';
import { extendBookshelfApi } from './express-extensions.js';
const app = express();
extendBookshelfApi(app);
app.get('/books', (req, res) => {
// req.requestedPageSize and res.ok are fully typed through the merged interfaces.
res.ok({ pageSize: req.requestedPageSize, mobile: req.isMobile });
});
app.listen(3000);
Source: TypeScript: Declaration Merging
Things to know about declaration merging:
-
The
.d.tsfile must be part of the compilation. List it underincludeintsconfig.json(orfiles), or import it once for its side effect. A declaration the compiler never reads silently does nothing, and the symptom is the original error onreq.isMobile. -
export {}(or any top-level import or export) turns the file into a module. Without it,declare globalis an error, and thenamespace Expressblock would instead be a global script declaration, which works but is easy to break. -
The augmentation is a promise, not an implementation. The compiler accepts
req.isMobileeverywhere, even in an application that never calledextendBookshelfApi. Keep the declaration and the installer in the same folder, and cover the installer with a test. -
Inside the installer, annotate
thiswith theRequestandResponsetypes imported fromexpress, not withExpress.RequestorExpress.Response. The global interfaces hold only the merged members, sothis.getorthis.statuswould not type check on them. -
Express.Localstypesres.localsandapp.localsat once. Properties declared there are optional by convention, because nothing guarantees that a middleware ran before the code that reads them. -
Fields set on the request by a library, such as
req.userfrom Passport, are typed the same way and conflict if two libraries declare the same name with different types. The compiler reports a duplicate property error, and the fix is to align the types or rename one field. -
The older style,
declare module 'express-serve-static-core'with the interfacesRequestandResponse, also works and is what many tutorials show. The globalExpressnamespace is the one the type packages document for application code, and it reaches theRequestandResponsetypes of both Express and the core package. -
A custom setting has no type.
app.get('bookshelf.maxPageSize')returnsany(the settings store is a free-form map), so wrap reads in a typed function such asgetMaxPageSize(app): numberinstead of casting at every call site.
The wider TypeScript setup, including strict mode, ESM and ts-node or tsx options, is in
TypeScript with Express.
Testing extensions
An extension applied to app.request can be tested in isolation, because every call of express() creates fresh
prototypes. That is the main argument for the per-application level: the tests of one installer cannot disturb another.
// test/express-extensions.test.js
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { test } from 'node:test';
import express from 'express';
import { extendBookshelfApi } from '../src/express-extensions.js';
test('req.requestedPageSize caps the value at 100', async () => {
const app = express();
extendBookshelfApi(app);
app.get('/size', (req, res) => res.ok({ size: req.requestedPageSize }));
const server = app.listen(0);
await once(server, 'listening');
try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/size?pageSize=5000`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { data: { size: 100 } });
} finally {
server.close();
}
});
Source: Node.js: Test runner
Common pitfalls
| Mistake | Symptom | Fix |
|---|---|---|
Using an arrow function for a getter or helper |
|
Use a regular |
Assigning a computed value with |
Every request reads the same value, set once. |
Define an accessor with |
Defining a property without |
|
Set |
Extending |
Another application, or a library’s application, gets the member too, and a test run in the same process sees the leftover. |
Extend |
Overriding |
Infinite recursion or a double wrapper, because |
Override the narrowest method, keep the original, and call it with |
Overriding a method with a different signature |
Third-party middleware breaks in unrelated places. |
Keep arguments and return values compatible, and add new names for new behaviour. |
Choosing a name that Express or Node.js may define later |
After an upgrade the member is shadowed, or the extension silently replaces a new built-in. |
Prefix custom members ( |
Defining the extension after the first route or the first request |
Early routes see |
Install extensions right after |
Forgetting that a mounted app has its own prototypes |
The member is missing inside a mounted sub-application. |
Install the extension on the sub-application too, or use a shared installer. |
Declaring types without implementing them |
It compiles, then fails at run time with |
Keep the |
Storing secrets in settings, |
Templates or error pages expose them. |
Keep secrets in environment variables and in services, never in the shared stores. |
Treating |
|
Use namespaced names that are not path-like, such as |
Changing prototypes inside a hot path |
Slower requests, because the engine discards optimisations on objects whose prototype changes. |
Prefer a function, a middleware or one application-level prototype installed at start-up. |
What changed recently
| Version | Change relevant to extending the API |
|---|---|
Express 4.x |
|
Express 5.0.0 (2024-09) |
The prototype layout is the same. Several |
Express 5.1.0 (2025-03-31) |
Express 5 becomes the default |
Express 5.2 (2025-12-01) and 5.3 (2026-10-09) |
Dependency and security updates. The prototypes, |
Where the books are outdated
The four books consulted for this section target Express 4.0 to 4.18 and Node.js 0.10 to 20. Their coverage of the Express API and of application settings needs the following corrections for current projects:
| Older practice | Current practice |
|---|---|
Configuring the application inside |
|
Starting the application with |
Removed in Express 4. Call |
Reading the application settings through |
Still valid, and the inheritance through the |
Relying on |
Removed in Express 5. Use |
Writing a plain JavaScript extension with no types |
A |
Adding properties directly on |
Still possible, and still global. Prefer |
Using |
Read |
References
-
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: Application & Settings, Middleware Fundamentals, TypeScript with Express, Template Engines & Views