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 }],
}), "auth"],
(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 }],
}), "auth"],
(req, res, acc) => ({
response: { body: { role: "admin" } },
}),
);
const userPipeline = compose(
[authorization({
strategies: [{ type: "Bearer", authorizer: verifyJwt }],
}), "auth"],
(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, window: 60_000 },
},
});
publicRouter.get("/search", { execute: search });
const premiumRouter = createRouter({
defaults: {
authorization: { strategies: [apiKeyStrategy] },
rateLimit: { max: 1000, window: 60_000 },
},
});
premiumRouter.get("/search", { execute: search });
const router = createRouter({
transport: {
rateLimit: { max: 10_000, window: 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, window: 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
AspectScopeWho Controls It
defaults (accepts, authorization, validate, etc.)ChildEach sub-router’s routes use the defaults they were registered with
router.use() middlewareChildBaked into child pipelines at registration time
send / catchHandlerChildRoute options from child’s config
Transport (CORS, security headers, rate limit, request ID)ParentRuns before routing at parent dispatch level
strictPatch / strictBodyParentContent-Type enforcement at parent dispatch
REST compliance (405, HEAD, OPTIONS)ParentApplied to the merged route table