Feature Request: Ability to list routes with method + middleware, one entry per HTTP method
Feature Request: Ability to list routes with method + middleware, one entry per HTTP method
Use case & goal
In medium to large applications, it's often difficult to inspect the full list of declared routes, especially when dealing with multiple routers, dynamic parameters, and complex middleware stacks.
This feature would help developers:
- Debug route registration and conflicts.
- Generate API documentation.
- Inspect which middleware are applied to each route.
Currently, developers have to dig into app._router.stack, which is undocumented and subject to change, or use third-party packages like express-list-endpoints. A native utility would make this more reliable and portable.
Example usage
const routes = app.getRoutes();
console.log(routes);
/*
[
{
method: 'GET',
path: '/users',
middlewares: ['authMiddleware']
},
{
method: 'POST',
path: '/users',
middlewares: ['authMiddleware', 'validateUser']
},
{
method: 'GET',
path: '/posts/:id',
middlewares: ['anonymous']
}
]
*/
Each route-method combination would be a separate entry for clarity and ease of processing.
Expected behavior
- List all declared routes in the application, including those inside routers (express.Router()).
- Return one object per method/path combination.
- Include the route method, full path, and the list of middleware names.
- Should rely on public APIs if possible (or expose one).
- Could be provided as app.getRoutes() or express.listRoutes(app).
Thanks for the amazing framework — would love to help improve the developer experience!
you kind of can already do something similar but... https://github.com/expressjs/express/issues/3308#issuecomment-300957572
I quickly (and not necessarily in an elegant way) adapted the script from the excellent package express-list-endpoints package, and it seems that this version work with Express 5. However, I’m not sure if the regular expressions are fully compatible with the new path-to-regexp@8.
The script currently returns a string list of all methods, routes, and their associated middlewares — not yet an array of objects, which would be more structured and convenient to work with.
This kind of functionality could be a great built-in feature in Express 5, helping developers map out routes and even build documentation or Swagger-like outputs more easily.
/*** a quick modification (and not necessarily in an elegant way) from https://www.npmjs.com/package/express-list-endpoints ***/
/**
* @typedef {Object} Route
* @property {Object} methods
* @property {string | string[]} path
* @property {any[]} stack
*
* @typedef {Object} Endpoint
* @property {string} path Path name
* @property {string[]} methods Methods handled
* @property {string[]} middlewares Mounted middlewares
*/
const regExpToParseExpressPathRegExp = /^\/\^\\?\/?(?:(:?[\w\\.-]*(?:\\\/:?[\w\\.-]*)*)|(\(\?:\\?\/?\([^)]+\)\)))\\\/.*/
const regExpToReplaceExpressPathRegExpParams = /\(\?:\\?\/?\([^)]+\)\)/
const regexpExpressParamRegexp = /\(\?:\\?\\?\/?\([^)]+\)\)/g
const regexpExpressPathParamRegexp = /(:[^)]+)\([^)]+\)/g
const EXPRESS_ROOT_PATH_REGEXP_VALUE = '/^\\/?(?=\\/|$)/i'
const STACK_ITEM_VALID_NAMES = [
'router',
'bound dispatch',
'mounted_app',
'<anonymous>'
]
/**
* Returns all the verbs detected for the passed route
* @param {Route} route
*/
const getRouteMethods = function (route) {
let methods = Object.keys(route.methods);
methods = methods.filter((method) => method !== "_all");
methods = methods.map((method) => method.toUpperCase());
return methods[0] ? methods : ["ALL"];
};
/**
* Returns the names (or anonymous) of all the middlewares attached to the
* passed route
* @param {Route} route
* @returns {string[]}
*/
const getRouteMiddlewares = function (route) {
return route.stack.map((item) => {
return item.name || item.handle.name || 'anonymous'
})
}
/**
* Returns true if found regexp related with express params
* @param {string} expressPathRegExp
* @returns {boolean}
*/
const hasParams = function (expressPathRegExp) {
return regexpExpressParamRegexp.test(expressPathRegExp)
}
/**
* @param {Route} route Express route object to be parsed
* @param {string} basePath The basePath the route is on
* @return {Endpoint[]} Endpoints info
*/
const parseExpressRoute = function (route, basePath) {
const paths = []
if (Array.isArray(route.path)) {
paths.push(...route.path)
} else {
paths.push(route.path)
}
/** @type {Endpoint[]} */
const endpoints = paths.map((path) => {
const completePath = basePath && path === '/'
? basePath
: `${basePath}${path}`
/** @type {Endpoint} */
const endpoint = {
path: completePath.replace(regexpExpressPathParamRegexp, '$1'),
methods: getRouteMethods(route),
middlewares: getRouteMiddlewares(route)
}
return endpoint
})
return endpoints
}
/**
* @param {RegExp} expressPathRegExp
* @param {any[]} params
* @returns {string}
*/
const parseExpressPath = function (expressPathRegExp, params) {
let parsedRegExp = expressPathRegExp.toString()
let expressPathRegExpExec = regExpToParseExpressPathRegExp.exec(parsedRegExp)
let paramIndex = 0
while (hasParams(parsedRegExp)) {
const paramName = params[paramIndex].name
const paramId = `:${paramName}`
parsedRegExp = parsedRegExp
.replace(regExpToReplaceExpressPathRegExpParams, (str) => {
// Express >= 4.20.0 uses a different RegExp for parameters: it
// captures the slash as part of the parameter. We need to check
// for this case and add the slash to the value that will replace
// the parameter in the path.
if (str.startsWith('(?:\\/')) {
return `\\/${paramId}`
}
return paramId
})
paramIndex++
}
if (parsedRegExp !== expressPathRegExp.toString()) {
expressPathRegExpExec = regExpToParseExpressPathRegExp.exec(parsedRegExp)
}
const parsedPath = expressPathRegExpExec[1].replace(/\\\//g, '/')
return parsedPath
}
/**
* @param {import('express').Express | import('express').Router | any} app
* @param {string} [basePath]
* @param {Endpoint[]} [endpoints]
* @returns {Endpoint[]}
*/
const parseEndpoints = function (app, basePath, endpoints) {
const stack = app.stack || (app._router && app._router.stack)
endpoints = endpoints || []
basePath = basePath || ''
if (!stack) {
if (endpoints.length) {
endpoints = addEndpoints(endpoints, [{
path: basePath,
methods: [],
middlewares: []
}])
}
} else {
endpoints = parseStack(stack, basePath, endpoints)
}
return endpoints
}
/**
* Ensures the path of the new endpoints isn't yet in the array.
* If the path is already in the array merges the endpoints with the existing
* one, if not, it adds them to the array.
*
* @param {Endpoint[]} currentEndpoints Array of current endpoints
* @param {Endpoint[]} endpointsToAdd New endpoints to be added to the array
* @returns {Endpoint[]} Updated endpoints array
*/
const addEndpoints = function (currentEndpoints, endpointsToAdd) {
endpointsToAdd.forEach((endpoint) => {
currentEndpoints.push({
"#": currentEndpoints.length + 1,
method: endpoint.methods[0],
path: endpoint.path,
middlewares: endpoint.middlewares
});
});
return currentEndpoints;
};
/**
* @param {any[]} stack
* @param {string} basePath
* @param {Endpoint[]} endpoints
* @returns {Endpoint[]}
*/
const parseStack = function (stack, basePath, endpoints) {
stack.forEach((stackItem) => {
if (stackItem.route) {
const newEndpoints = parseExpressRoute(stackItem.route, basePath);
endpoints = addEndpoints(endpoints, newEndpoints);
} else {
const isExpressPathRegexp = regExpToParseExpressPathRegExp.test(stackItem.regexp);
let newBasePath = basePath;
if (isExpressPathRegexp) {
const parsedPath = parseExpressPath(stackItem.regexp, stackItem.keys);
newBasePath += `/${parsedPath}`;
} else if (!stackItem.path && stackItem.regexp && stackItem.regexp.toString() !== EXPRESS_ROOT_PATH_REGEXP_VALUE) {
const regExpPath = ` RegExp(${stackItem.regexp}) `;
newBasePath += `/${regExpPath}`;
}
if (STACK_ITEM_VALID_NAMES.includes(stackItem.name)) endpoints = parseEndpoints(stackItem.handle, newBasePath, endpoints);
else {
if (!useMiddlewares[newBasePath]) useMiddlewares[newBasePath] = [];
useMiddlewares[newBasePath].push(stackItem.name);
}
}
});
return endpoints;
};
/**
* Returns an array of strings with all the detected endpoints
* @param {import('express').Express | import('express').Router | any} app The express/router instance to get the endpoints from
* @returns {Endpoint[]}
*/
let useMiddlewares = {};
const expressListEndpoints = function (app) {
const endpoints = parseEndpoints(app);
let ret = [];
endpoints.forEach((endpoint, i) => {
for (const path in useMiddlewares) {
if (path && endpoint.path.startsWith(path)) endpoint.middlewares.splice(0, 0, ...(useMiddlewares[path] || []));
}
ret.push(`${String(i + 1).padEnd(4, ' ')} - ${(endpoint?.method || "").padEnd(6, ' ')} - ${endpoint.path.padEnd(30, ' ')} - ${endpoint.middlewares.join(", ")}`);
});
return ret;
};
export default expressListEndpoints
It seems that many developers have tried to achieve the same goal using different approaches (stackoverflow). All the solutions rely on some kind of reverse engineering to figure out how to navigate the Express stack. That’s why I believe the best approach would be to have a built-in function — to avoid misunderstandings and misinterpretations of how the Express stack works.
With the help of my AI friend 😉, I completely rewrote the listRoutes function, and overall everything is running really well! The only challenge left is detecting sub-router routes. I've explored many different approaches, but it seems very difficult — if not impossible — to retrieve the sub-route paths passed with statements like app.use("/subpath", router). It looks like these paths simply aren't available in the stack.
Any insights, tips, or ideas would be truly appreciated! It's an exciting problem to work on, and it really highlights how valuable it would be for Express 5 to offer a built-in way to list all registered routes, instead of having to dive deep and reverse-engineer the framework.
Thanks a lot in advance for any help!
/**
* Extracts all routes from an Express 5.1.0 app or Router.
* If options.format = true, returns an array of formatted strings;
* otherwise returns an array of { method, route, middlewares }.
*
* @param {import('express').Application|import('express').Router} appOrRouter
* @param {{ format?: boolean }} options
* @returns {Array<{ method: string, route: string, middlewares: string[] }> | string[]}
*/
listRoutes(appOrRouter, options = {}) {
const routes = [];
const root = appOrRouter._router || appOrRouter;
const stack = root.stack || [];
// Try to recover a real function name, even from anonymous/factory fns
function getMiddlewareName(fn) {
if (fn.name) return fn.name;
const m = /^function\s*([\w$]+)/.exec(fn.toString());
return m ? m[1] : "<anonymous>";
}
// Join two URL segments, ensuring exactly one "/" between them
function joinPaths(a, b) {
if (!b) return a;
const A = a.endsWith("/") ? a.slice(0, -1) : a;
const B = b.startsWith("/") ? b : "/" + b;
return A + B;
}
// Given any Layer, figure out its "mount path" portion:
// • layer.path (Express 5 sets this on .use)
// • layer.route.path (for route layers)
// • fallback: parse layer.regexp.toString()
function getLayerPath(layer) {
if (layer.path != null) {
return layer.path === "/" ? "" : layer.path;
}
if (layer.route && layer.route.path != null) {
return layer.route.path === "/" ? "" : layer.route.path;
}
if (layer.regexp) {
// fast_slash means "/"
if (layer.regexp.fast_slash) return "";
// strip off the /^ \/foo \/?(?=\/|$) /i wrapper
let str = layer.regexp
.toString()
.replace(/^\/\^/, "")
.replace(/\\\/\?\(\?=\\\/\|\$\)\/i$/, "")
.replace(/\\\//g, "/")
.replace(/\\\./g, ".");
// replace capture groups with :param
const keys = (layer.keys || []).map((k) => `:${k.name}`);
str = str.replace(/\((?:\?:)?[^\)]+\)/g, () => keys.shift() || "");
return str.startsWith("/") ? str : "/" + str;
}
return "";
}
/**
* Recursively walk a stack of Layers, accumulating:
* - `prefix` the URL so far ("/v7", then "/v7/webhooks", etc.)
* - `parentMW` the array of middleware names mounted above this point
*/
function traverse(stack, prefix = "", parentMW = []) {
stack.forEach((layer, idx) => {
// ─── Case 1: a direct route (app.get / router.post / etc) ───
if (layer.route) {
const routePath = layer.route.path || "";
const fullPath = joinPaths(prefix, routePath);
const methods = Object.keys(layer.route.methods).map((m) => m.toUpperCase());
const routeMW = layer.route.stack.map((l) => getMiddlewareName(l.handle));
const allMW = parentMW.concat(routeMW);
methods.forEach((method) => {
routes.push({ method, route: fullPath, middlewares: allMW });
});
// ─── Case 2: a mounted router or .use(path, fn, ...) block ───
} else if (layer.handle && layer.handle.stack) {
const mountPath = getLayerPath(layer);
const fullPref = joinPaths(prefix, mountPath);
// gather any plain middleware functions mounted at this same mountPath
const mountMW = stack
.slice(0, idx)
.filter(
(prev) =>
typeof prev.handle === "function" &&
!prev.handle.stack && // not a router
getLayerPath(prev) === mountPath // same mount path
)
.map((prev) => getMiddlewareName(prev.handle));
// recurse into child stack, carrying forward middleware names
traverse(layer.handle.stack, fullPref, parentMW.concat(mountMW));
}
// else: a logger, error handler, or global middleware—skip it
});
}
traverse(stack);
// If asked for formatted strings, pad each column to align the dashes
if (options.format) {
const idxW = String(routes.length).length;
const mthW = Math.max(...routes.map((r) => r.method.length), 6);
const rteW = Math.max(...routes.map((r) => r.route.length), 5);
return routes.map((r, i) => {
const idx = String(i + 1).padEnd(idxW, " ");
const mtd = r.method.padEnd(mthW, " ");
const rte = r.route.padEnd(rteW, " ");
const mws = r.middlewares.join(", ");
return `${idx} - ${mtd} - ${rte} - ${mws}`;
});
}
return routes;
}
I would love for this to become part of the framework as well. In 4.X that I was using I too used the app._router._stack approach but just switching to 5.X those internal variables are gone which makes sense. Having a framework capability would be great. We use this for auditing purposes to see if any routes we have created have never been called.
Hi everyone 👋
I’m following up on this ticket to gather some feedback from the maintainers and contributors.
Since Express 5 no longer exposes the internal routing structure (such as app._router.stack), it's no longer possible to introspect routes reliably — and external modules that relied on it no longer work either. This makes it difficult to list defined routes (methods, paths, middleware) for purposes like debugging, documentation, or tooling.
Given this situation, I’m wondering what you think about the idea of adding a public API like app.getRoutes() or express.listRoutes(app) to expose this information in a supported and stable way.
Does this seem compatible with the project's philosophy? Are there concerns or past discussions I might have missed?
Thanks in advance for your thoughts!
@infuzz - I don't have much history. I'm not sure if you are a maintainer but I would really love a public api to get this information. How I use this information is I create an audit record of all the endpoints that are registered and store that in a redis cache. I increment when endpoints are called in the cache. This way I can see if there are dead endpoints that are never called and no longer needed.
try app.router.stack in express 5.x
@sheplu @infuzz Sorry for the ping but any progress on getting something like this implemented on your roadmap 👀
The solution provided above did an ok job and can get the job done for now but had some flaws and wondering if something like this is going to be supported long term.
For my use case, Im using express-prom-bundle to report metrics to prometheus. We are using https://github.com/jochen-schweizer/express-prom-bundle?tab=readme-ov-file#example-3-return-express-route-definition soo that it reports the routes we define in express soo parameterized routes get reported as the same route.
However we have some middleware attached to our API that returns a 401 before hitting any routes. The issue is that on routes that have params (example: /api/some/:param/route), they always get reported in the originalUrl form (example: /api/some/myValue/route) because the middleware is hit before req.route?.path is set. Regular status codes always get the right value. Soo my goal is to use this logic to pragmatically fetch the route it was supposed to hit to I can report the correct express route back to Prometheus instead of the originalUrl.
fyi https://github.com/pillarjs/router/pull/174 is ready to merge so hopefully we'll see a proper and maintained solution soon!
Looking forward to it @pw-64