Skip to content

Sub-Routers

You are building a multi-resource API where different route groups need different auth strategies, middleware defaults, or rate limits. Putting everything on a single router forces you to override config on every route — violating DRY and obscuring the group-level intent.

Create a sub-router with its own defaults and mount it at a prefix:

import {compose, authorization} from '@centralping/ergo';
// Each group composes its own pipeline independently
const adminPipeline = compose(
authorization({
strategies: [{type: 'Basic', authorizer: verifyAdmin}],
}),
(req, res, acc) => ({
response: {body: {admin: true}},
}),
);

Different route groups use different authentication — admin routes require Basic, user routes require Bearer, and public routes skip auth entirely:

import {compose, authorization} from '@centralping/ergo';
const adminPipeline = compose(
authorization({
strategies: [{type: 'Basic', authorizer: verifyAdmin}],
}),
(req, res, acc) => ({
response: {body: {role: 'admin'}},
}),
);
const userPipeline = compose(
authorization({
strategies: [{type: 'Bearer', authorizer: verifyJwt}],
}),
(req, res, acc) => ({
response: {body: {userId: acc.auth.user.id}},
}),
);
const publicPipeline = compose(
(req, res, acc) => ({
response: {body: {status: 'healthy'}},
}),
);

Child router defaults do not merge with parent defaults. Each sub-router’s routes use only the defaults they were registered with:

import createRouter from '@centralping/ergo-router';
const parent = createRouter({
defaults: {
accepts: {types: ['application/json']},
timeout: {ms: 5000},
},
});
const child = createRouter({
defaults: {
authorization: {strategies: [bearerStrategy]},
},
});
child.get('/items', {execute: listItems});
parent.mount('/api', child);
// GET /api/items has authorization (from child defaults)
// but does NOT have accepts or timeout (from parent defaults)

To intentionally share defaults between parent and child, spread them:

const sharedDefaults = {
accepts: {types: ['application/json']},
timeout: {ms: 5000},
};
const parent = createRouter({
defaults: sharedDefaults,
transport: {/* ... */},
});
const child = createRouter({
defaults: {
...sharedDefaults,
authorization: {strategies: [bearerStrategy]},
},
});

Different sub-routers can set different rateLimit defaults to enforce per-group limits at the pipeline level:

import createRouter from '@centralping/ergo-router';
const publicRouter = createRouter({
defaults: {
rateLimit: {max: 100, windowMs: 60_000},
},
});
publicRouter.get('/search', {execute: search});
const premiumRouter = createRouter({
defaults: {
authorization: {strategies: [apiKeyStrategy]},
rateLimit: {max: 1000, windowMs: 60_000},
},
});
premiumRouter.get('/search', {execute: search});
const router = createRouter({
transport: {
rateLimit: {max: 10_000, windowMs: 60_000},
},
});
router
.mount('/v1', publicRouter)
.mount('/premium', premiumRouter);

A practical example combining transport on the parent, per-group defaults on children, and mount chaining:

import createRouter from '@centralping/ergo-router';
// --- Sub-routers with per-group concerns ---
const projectsRouter = createRouter({
defaults: {
authorization: {strategies: [bearerStrategy]},
accepts: {types: ['application/json']},
},
});
projectsRouter.get('/', {execute: listProjects});
projectsRouter.post('/', {
validate: {body: projectSchema},
execute: createProject,
});
projectsRouter.get('/:id', {execute: getProject});
const adminRouter = createRouter({
defaults: {
authorization: {
strategies: [{type: 'Basic', authorizer: verifyAdmin}],
},
accepts: {types: ['application/json']},
},
});
adminRouter.get('/users', {execute: listAllUsers});
adminRouter.delete('/users/:id', {execute: deleteUser});
const healthRouter = createRouter();
healthRouter.get('/', {
authorization: false,
execute: (req, res, acc) => ({
response: {body: {status: 'ok', ts: Date.now()}},
}),
});
// --- Parent router with transport ---
const router = createRouter({
transport: {
requestId: true,
security: {hsts: {maxAge: 31_536_000}},
cors: {origin: 'https://app.example.com'},
rateLimit: {max: 10_000, windowMs: 60_000},
},
strictPatch: true,
strictBody: true,
});
router
.mount('/projects', projectsRouter)
.mount('/admin', adminRouter)
.mount('/health', healthRouter);

router.mount(prefix, subRouter) copies all currently registered routes from the child into the parent’s dispatcher with the prefix prepended to each path. This is a one-time copy, not a live delegation — the parent and child are independent after mounting.

This means:

  1. Order matters — register all routes on the child before calling mount() on the parent
  2. No runtime delegation — the parent’s find-my-way dispatcher handles all matching directly (no double-dispatch overhead)
  3. Pipelines are frozen — each child route’s pipeline was built at addRoute() time using the child’s defaults; mounting does not re-resolve
Aspect Scope Who Controls It
defaults (accepts, authorization, validate, etc.) Child Each sub-router’s routes use the defaults they were registered with
router.use() middleware Child Baked into child pipelines at registration time
send / catchHandler Child Route options from child’s config
Transport (CORS, security headers, rate limit, request ID) Parent Runs before routing at parent dispatch level
strictPatch / strictBody Parent Content-Type enforcement at parent dispatch
REST compliance (405, HEAD, OPTIONS) Parent Applied to the merged route table