[!TAG] 0.2.0
A dynamic route composition system for Express.js applications that automatically discovers and mount routes and middleware based on your file system structure. Inspired by Next.js routing conventions.
[paramName]
syntax that converts to Express.js :paramName
format_middleware.js
and index.js
filesNPM
npm install @psenger/express-auto-router --save
YARN
yarn add @psenger/express-auto-router
Express Auto Router is an elegant solution that transforms your directory structure into a fully functional Express.js routing system. It follows the philosophy of “convention over configuration” similar to Next.js and Nuxt.js, but for Express.js backend applications.
The system uses your file system structure to automatically generate Express routes. For example:
routes/
├── _middleware.js # Global middleware
├── users/
│ ├── _middleware.js # Users-specific middleware
│ ├── index.js # /users/ endpoint
│ └── [id]/ # Dynamic parameter
│ ├── _middleware.js # User-specific middleware
│ └── index.js # /users/:id/ endpoint
[paramName]
for dynamic route parameters:paramName
)/users/[userId]/posts/[postId]/
becomes /users/:userId/posts/:postId/
One of the most powerful features is the hierarchical middleware system:
_middleware.js
file/api/users/123/
will execute middleware in this order:
/api/_middleware.js
/api/users/_middleware.js
/api/users/[id]/_middleware.js
An opinionated decision in the code is the use of trailing slashes (/
). The router is configured with { strict: true }
, which means:
/users
and /users/
are treated as different routesconst routerOptions = options.routerOptions || { strict: true }
The code enforces strict mode by default, treating trailing slashes as significant.
export function isMiddlewareFile(entry) {
return entry.isFile() && entry.name === '_middleware.js'
}
The system expects middleware files to be named exactly _middleware.js
.
Hierarchical Middleware Organization
The dictionaryKeyStartsWithPath
function enforces a hierarchical middleware structure, sorting by path length to ensure proper execution order. Please note this is an opinion of how middleware should work and is baked into this system. If you want to control this it would have to be done inside the middleware.
Global parameters/options can be passed to the controllers and middleware like this
const middlewareOptions = { logLevel: debug }
const controllerOptions = { env: 'test' }
composeRoutes(express, routeMappings, { middlewareOptions, controllerOptions } )
You should write your Controllers like this.
module.exports = ( router, controllerOptions ) => {
...
return router
}
You should write your Middleware like this.
module.exports = ( middlewareOptions ) => {
return [
...
]
}
The biggest potential issue is the strict trailing slash requirement:
Pros:
Cons:
Mitigation Strategies:
location / {
proxy_pass http://backend;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
}
While the hierarchical middleware system is powerful, it can lead to unexpected behavior if not carefully managed. The code allows for fine-grained control through:
[paramName]
This system provides a powerful and elegant solution for Express.js routing, but users should be aware of its opinions and potential infrastructure considerations, particularly regarding the trailing slash requirement.
Global | Description |
---|---|
isMiddlewareFile(entry) ⇒ boolean |
Checks if a directory entry is a middleware file |
autoBox(ary) ⇒ Array |
Ensures a value is always an array by wrapping non-array values |
replaceUrlPlaceholders(urlPath) ⇒ string |
Converts URL placeholder syntax [param] to Express parameter syntax :param |
isPlaceholder(urlPath) ⇒ boolean |
Checks if a URL path contains a placeholder |
validatePath(path) | Validates if a path is a non-empty string |
dictionaryKeyStartsWithPath(dictionary, path) ⇒ Array.<function()> |
Retrieves and sorts middleware functions that match a given path Finds all entries in the dictionary where the given path starts with the dictionary key, sorts them by key length (shortest first), and returns the flattened array of middleware functions |
curryObjectMethods(router, urlPath, ...initialMiddleWareFunctions) ⇒ Object |
Creates a curried router object with pre-configured URL path and middleware Returns a proxy to the original router that applies the given URL path and middleware functions to all HTTP method calls (get, post, put, etc.) automatically |
buildMiddlewareDictionary(basePath, baseURL, [options]) ⇒ Object.<string, Array.<function()>> |
Builds a dictionary of middleware functions from a directory structure Recursively scans the given directory for '_middleware.js' files and builds a dictionary mapping URL paths to their corresponding middleware functions |
buildRoutes(basePath, baseURL) ⇒ Array.<Array.<string>> |
Builds an array of route mappings from a directory structure Recursively scans the given directory for 'index.js' files and builds an array of URL paths and their corresponding file paths, converting directory placeholders to Express params |
composeRoutes(express, routeMappings, [options]) ⇒ Object |
Composes Express routes from a directory structure with middleware support. This is the main function that processes route mappings, builds middleware dictionaries, and configures an Express router with all discovered routes and middleware. |
boolean
Checks if a directory entry is a middleware file
Kind: global function
Returns: boolean
- - True if the entry is a file named ‘_middleware.js’
Param | Type | Description |
---|---|---|
entry | Object |
The directory entry to check (fs.Dirent object) |
Example
// With a file entry for '_middleware.js'
const middlewareEntry = { isFile: () => true, name: '_middleware.js' };
isMiddlewareFile(middlewareEntry); // Returns: true
Example
// With a directory entry
const dirEntry = { isFile: () => false, name: '_middleware.js' };
isMiddlewareFile(dirEntry); // Returns: false
Example
// With a different file
const otherFileEntry = { isFile: () => true, name: 'index.js' };
isMiddlewareFile(otherFileEntry); // Returns: false
Array
Ensures a value is always an array by wrapping non-array values
Kind: global function
Returns: Array
- - Wraps the value in an array, or if the input was an array already it will return it as is.
Param | Type | Description |
---|---|---|
ary | \* |
The value to convert to an array |
Example
// With a non-array value
autoBox(5); // Returns: [5]
Example
// With an array value
autoBox([1, 2, 3]); // Returns: [1, 2, 3]
Example
// With null or undefined
autoBox(null); // Returns: [null]
autoBox(undefined); // Returns: [undefined]
Example
// With an object
autoBox({ key: 'value' }); // Returns: [{ key: 'value' }]
string
Converts URL placeholder syntax [param] to Express parameter syntax :param
Kind: global function
Returns: string
- - The URL path with Express-style parameters
Param | Type | Description |
---|---|---|
urlPath | string |
The URL path containing placeholders |
Example
// With single placeholder
replaceUrlPlaceholders('/users/[id]'); // Returns: '/users/:id'
Example
// With multiple placeholders
replaceUrlPlaceholders('/users/[id]/posts/[postId]'); // Returns: '/users/:id/posts/:postId'
Example
// With no placeholders
replaceUrlPlaceholders('/users/list'); // Returns: '/users/list'
Example
// With nested/complex placeholders
replaceUrlPlaceholders('/products/[category]/[id]/reviews/[reviewId]');
// Returns: '/products/:category/:id/reviews/:reviewId'
boolean
Checks if a URL path contains a placeholder
Kind: global function
Returns: boolean
- - True if the path contains a placeholder
Param | Type | Description |
---|---|---|
urlPath | string |
The URL path to check |
Example
// With placeholder
isPlaceholder('/users/[id]'); // Returns: true
Example
// With multiple placeholders
isPlaceholder('/users/[id]/posts/[postId]'); // Returns: true
Example
// Without placeholder
isPlaceholder('/users/list'); // Returns: false
Example
// With square brackets in a different context (not a placeholder)
isPlaceholder('/users/list[all]'); // Returns: true (matches the regex pattern)
Validates if a path is a non-empty string
Kind: global function Throws:
Error
If path is not a string or is emptyParam | Type | Description |
---|---|---|
path | string |
The path to validate |
Example
// With valid path
validatePath('/api/users'); // No error thrown
Example
// With empty string
try {
validatePath('');
} catch (error) {
console.error(error.message); // Outputs: 'Invalid path provided'
}
Example
// With null value
try {
validatePath(null);
} catch (error) {
console.error(error.message); // Outputs: 'Invalid path provided'
}
Example
// With non-string value
try {
validatePath(123);
} catch (error) {
console.error(error.message); // Outputs: 'Invalid path provided'
}
Array.<function()>
Retrieves and sorts middleware functions that match a given path Finds all entries in the dictionary where the given path starts with the dictionary key, sorts them by key length (shortest first), and returns the flattened array of middleware functions
Kind: global function
Returns: Array.<function()>
- - Array of middleware functions that apply to the path, ordered by path specificity
Param | Type | Description |
---|---|---|
dictionary | Object.<string, (function()\|Array.<function()>)> |
Dictionary of paths to middleware functions |
path | string |
The path to match |
Example
// With matching paths
const dict = {
'/api/': [authMiddleware],
'/api/users/': [userMiddleware]
};
dictionaryKeyStartsWithPath(dict, '/api/users/profile');
// Returns: [authMiddleware, userMiddleware] (in order from least to most specific)
Example
// With no matching paths
const dict = {
'/api/': [authMiddleware],
'/api/users/': [userMiddleware]
};
dictionaryKeyStartsWithPath(dict, '/admin/');
// Returns: []
Example
// With mixed array and single function values
const dict = {
'/api/': [authMiddleware, logMiddleware],
'/api/users/': userMiddleware
};
dictionaryKeyStartsWithPath(dict, '/api/users/');
// Returns: [authMiddleware, logMiddleware, userMiddleware]
Example
// With null or undefined values in the dictionary (they are filtered out)
const dict = {
'/api/': [authMiddleware, null],
'/api/users/': undefined
};
dictionaryKeyStartsWithPath(dict, '/api/users/');
// Returns: [authMiddleware]
Object
Creates a curried router object with pre-configured URL path and middleware Returns a proxy to the original router that applies the given URL path and middleware functions to all HTTP method calls (get, post, put, etc.) automatically
Kind: global function
Returns: Object
- - Curried router proxy with pre-configured path and middleware
Param | Type | Description |
---|---|---|
router | Object |
Express router instance |
urlPath | string |
The URL path to be curried |
…initialMiddleWareFunctions | function |
Initial middleware functions to be applied (rest parameter, accepts multiple functions) |
Example
// Basic usage with a single middleware function
const router = express.Router();
const curriedRouter = curryObjectMethods(router, '/users', authMiddleware);
curriedRouter.get((req, res) => res.json({}));
// Equivalent to: router.get('/users', authMiddleware, (req, res) => res.json({}));
Example
// With multiple middleware functions
const curriedRouter = curryObjectMethods(router, '/posts', authMiddleware, logMiddleware);
curriedRouter.post((req, res) => res.status(201).json({}));
// Equivalent to: router.post('/posts', authMiddleware, logMiddleware, (req, res) => res.status(201).json({}));
Example
// With no middleware
const curriedRouter = curryObjectMethods(router, '/public');
curriedRouter.get((req, res) => res.send('Hello'));
// Equivalent to: router.get('/public', (req, res) => res.send('Hello'));
Example
// Accessing the original router object
const curriedRouter = curryObjectMethods(router, '/api');
const originalRouter = curriedRouter._getOriginalObject();
// originalRouter is the router instance passed in the first parameter
Object.<string, Array.<function()>>
Builds a dictionary of middleware functions from a directory structure Recursively scans the given directory for ‘_middleware.js’ files and builds a dictionary mapping URL paths to their corresponding middleware functions
Kind: global function
Returns: Object.<string, Array.<function()>>
- Dictionary where keys are URL paths and values are arrays of middleware functions
Param | Type | Description |
---|---|---|
basePath | string |
Base filesystem path to start scanning |
baseURL | string |
Base URL path for the routes |
[options] | Object |
Options that can be passed to all controllers when they are executed. |
Example
// Basic directory structure with middleware
// ./src/routes/_middleware.js -> exports a global middleware
// ./src/routes/users/_middleware.js -> exports a users-specific middleware
const middlewares = buildMiddlewareDictionary('./src/routes', '/api');
// Returns: {
// '/api/': [globalMiddleware],
// '/api/users/': [usersMiddleware]
// }
Example
// With dynamic route parameters
// ./src/routes/users/[id]/_middleware.js -> exports a user-specific middleware
const middlewares = buildMiddlewareDictionary('./src/routes', '/api');
// Returns: {
// '/api/': [globalMiddleware],
// '/api/users/': [usersMiddleware],
// '/api/users/:id/': [userSpecificMiddleware]
// }
Example
// With middleware exporting multiple functions
// ./src/routes/_middleware.js -> exports [authMiddleware, logMiddleware]
const middlewares = buildMiddlewareDictionary('./src/routes', '/api');
// Returns: {
// '/api/': [authMiddleware, logMiddleware]
// }
Example
// With middleware exporting a single function
// ./src/routes/_middleware.js -> exports singleMiddleware (not in an array)
const middlewares = buildMiddlewareDictionary('./src/routes', '/api');
// Returns: {
// '/api/': [singleMiddleware]
// }
Array.<Array.<string>>
Builds an array of route mappings from a directory structure Recursively scans the given directory for ‘index.js’ files and builds an array of URL paths and their corresponding file paths, converting directory placeholders to Express params
Kind: global function
Returns: Array.<Array.<string>>
- Array of tuples where first element is URL path and second is file path
Param | Type | Description |
---|---|---|
basePath | string |
Base filesystem path to start scanning |
baseURL | string |
Base URL path for the routes |
Example
// Basic directory structure
// ./src/routes/users/index.js
// ./src/routes/posts/index.js
const routes = buildRoutes('./src/routes', '/api');
// Returns: [
// ['/api/users/', './src/routes/users/index.js'],
// ['/api/posts/', './src/routes/posts/index.js']
// ]
Example
// With dynamic route parameters
// ./src/routes/users/[id]/index.js
const routes = buildRoutes('./src/routes', '/api');
// Returns: [
// ['/api/users/:id/', './src/routes/users/[id]/index.js']
// ]
Example
// With nested dynamic routes
// ./src/routes/users/[userId]/posts/[postId]/index.js
const routes = buildRoutes('./src/routes', '/api');
// Returns: [
// ['/api/users/:userId/posts/:postId/', './src/routes/users/[userId]/posts/[postId]/index.js']
// ]
Example
// With root route
// ./src/routes/index.js
const routes = buildRoutes('./src/routes', '/api');
// Returns: [
// ['/api/', './src/routes/index.js']
// ]
Object
Composes Express routes from a directory structure with middleware support. This is the main function that processes route mappings, builds middleware dictionaries, and configures an Express router with all discovered routes and middleware.
Kind: global function
Returns: Object
- Configured Express router with applied routes
Param | Type | Description |
---|---|---|
express | Object |
The Express module instance |
routeMappings | Array.<Object> |
Array of route mapping configurations |
routeMappings[].basePath | string |
Base filesystem path to start scanning |
routeMappings[].baseURL | string |
Base URL path for the routes |
[options] | Object |
Configuration options |
[options.routerOptions] | Object |
Options for the Express router (default: { strict: true } stay with this for best results but be advised it makes paths require to be terminated with / ) |
[options.middlewareOptions] | Object |
Options passed to every middleware. |
[options.controllerOptions] | Object |
Options passed to every controller. |
Example
// Basic usage with a single route mapping
const express = require('express');
const app = express();
const router = composeRoutes(express, [
{
basePath: './src/routes',
baseURL: '/api'
}
]);
app.use(router);
// This will set up all routes found in './src/routes' with their middleware
Example
// With multiple route mappings
const router = composeRoutes(express, [
{
basePath: './src/api/routes',
baseURL: '/api'
},
{
basePath: './src/admin/routes',
baseURL: '/admin'
}
]);
Example
// With custom router options
const router = composeRoutes(express, [
{
basePath: './src/routes',
baseURL: '/api'
}
], {
routerOptions: {
strict: true,
}
});
Example
// With an existing router instance
const existingRouter = express.Router();
const router = composeRoutes(express, [
{
basePath: './src/routes',
baseURL: '/api'
}
], {
router: existingRouter
});
Lets make some assumptions, and then walk through what will happen.
src/routes
├── closed
│ └── organizations
│ └── [organizationId]
│ ├── clients
│ │ └── [clientId]
│ │ ├── contracts
│ │ │ └── index.js
│ │ └── projects
│ │ └── index.js
│ └── departments
│ └── [departmentId]
│ ├── employees
│ │ ├── [employeeId]
│ │ │ ├── projects
│ │ │ │ └── index.js
│ │ │ └── tasks
│ │ │ └── index.js
│ │ └── index.js
│ └── subdepartments
│ └── [subDepartmentId]
│ └── employees
│ ├── [employeeId]
│ │ ├── projects
│ │ │ └── index.js
│ │ └── tasks
│ │ └── index.js
│ └── index.js
└── open
├── _middleware.js
├── blog-posts
│ ├── [blogPostId]
│ │ └── index.js
│ ├── _middleware.js
│ └── index.js
└── users
├── [userId]
│ ├── blog-posts
│ │ ├── _middleware.js
│ │ └── index.js
│ ├── friends
│ │ ├── [friendId]
│ │ │ └── blog-posts
│ │ │ ├── _middleware.js
│ │ │ └── index.js
│ │ └── index.js
│ └── index.js
└── index.js
This program will scan a directory structure and build URLs and Path Parameters based on the following rules:
[
) followed by some text that can be a valid Express path parameter
and then closed off by a closing bracket ]
index.js
or _middleware.js
_middleware.js
accepts no parameters, however this is to allow you to perform Dependency Injection (DI) for the
purpose of testing and isolation ( more on this later )
index.js
accepts a Express Router and is expected to return that router with controllers ( and local middleware )
attached to the router object.
index.js
you will not need to provide a path, the code will do that for you, it will even convert
the path variables to Express Path variables.index.js
A simple example without a local middleware
const standard_controllers = (req, res, _next) => res.status(200).send({
route: `${req.baseUrl}${req.route.path}`,
params: req.params
})
module.exports = (router) => {
router.get(standard_controllers)
router.post(standard_controllers)
router.put(standard_controllers)
router.patch(standard_controllers)
router.delete(standard_controllers)
return router
}
Another simple example with localized middleware ( it will only apply to requests made into this path )
const microMiddleware = (req, res, next) => {
req.params = req.params || {};
req.params.context = req.params.context || {};
Object.assign(req.params.context, { microMiddleware: true })
next()
}
const standard_controllers = (req, res, _next) => res.status(200).send({
route: `${req.baseUrl}${req.route.path}`,
params: req.params
})
module.exports = (router) => {
router.get(microMiddleware, standard_controllers)
router.post(microMiddleware, standard_controllers)
router.put(microMiddleware, standard_controllers)
router.patch(microMiddleware, standard_controllers)
router.delete(microMiddleware, standard_controllers)
return router
}
_middleware.js
A single middleware that will be applied to the current directory ( end point ) and all subsequent paths.
module.exports = () => {
function standard_middleware(req, res, next) {
req.params = req.params || {};
req.params.context = req.params.context || {};
// Merge the context object with req.params.context
Object.assign(req.params.context, { blogPost: true })
next()
}
return standard_middleware
}
Multiple middleware, with importance on the order of execution, applied to the current directory ( end point ) and all subsequent paths.
module.exports = () => {
function standard_middleware_must_go_first(req, res, next) {
req.params = req.params || {};
req.params.context = req.params.context || {};
// Merge the context object with req.params.context
Object.assign(req.params.context, { first: true })
next()
}
function standard_middleware_must_go_second(req, res, next) {
req.params = req.params || {};
req.params.context = req.params.context || {};
if (req.params.context.first) {
Object.assign(req.params.context, { second: true })
return next()
}
next(new Error('Missing required first execution of the middleware'))
}
return [
standard_middleware_must_go_first,
standard_middleware_must_go_second
]
}
const express = require('express')
const { join } = require('path')
const composeRoutes = require('@psenger/express-auto-router').default
const app = express()
const routeMappings = [
{
basePath: join(process.cwd(), 'src', 'routes', 'open'),
baseURL: '/open'
},
{
basePath: join(process.cwd(), 'src', 'routes', 'closed'),
baseURL: '/closed'
}
]
app.use('/api', composeRoutes(express, routeMappings))
module.exports = app
Thanks for contributing! 😁 Here are some rules that will make your change to markdown-fences fruitful.
.editorconfig
and .eslintrc
.editorconfig
and .eslintrc
This module uses release-please which needs commit messages to look like the following Conventional Commits
<type>[optional scope]: <description>
[optional body]
type is typically fix
, feat
. When type ends with a !
or is BREAKING CHANGE
it indicates this is a breaking change.
type should be followed by a short description, **
optional body can have more detail
dist/index.js
NOT your src code. Therefore, you should BUILD it first.MIT License
Copyright (c) 2025 Philip A Senger
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
This project directly uses the following open-source packages: