How to mount and group routes
Organize a growing API into feature routers, mount them under path prefixes, and share middleware across a group without repeating it on every route.
You can build a small API on a single router. As the project grows, that one file turns into a bottleneck: users, posts, and admin routes compete for space, the same /api/v1 prefix is pasted onto every path, and the same auth check is copied onto every admin route. This guide reorganizes that into feature routers that scale with your app.
What you'll build
By the end you'll have an API split into feature routers, mounted under a version prefix, with shared middleware applied once per group:
- ✓ One router per feature (users, posts, admin)
- ✓ A versioned API mounted at
/api/v1 - ✓ Admin routes behind a shared auth guard
Resulting routes:
GET /api/v1/users
GET /api/v1/posts
GET /api/v1/admin/dashboard (requires auth)Before and after
Before
- ❌ One large router holding every feature
- ❌ The same middleware pasted onto route after route
- ❌
/api/v1copied into every path
After
- ✓ A small router per feature, testable on its own
- ✓ Shared middleware declared once per group
- ✓ The version prefix set in one place, at mount time
Prerequisites
- Concepts: Routing (how a
Routermatches) and Middleware (the onion pipeline) - Required knowledge: basic NextRush app setup (
createApp/listen) - Difficulty · time: Beginner · ~10 minutes
What you're building
Recommended approach
- Use
mount(prefix, subRouter)because it rebases a feature router's paths under a prefix and states that intent at the call site. - Avoid
use(prefix, router)because it does the same thing in an older, less explicit form — both work, butmountreads clearly. - Alternative:
group(prefix, [middleware], callback)carves a prefixed section within one router and attaches middleware to all of it. - When to choose the alternative: when a set of routes shares both a prefix and middleware but doesn't warrant its own file.
Split each feature into its own router
Why: a feature router that knows nothing about where it will be mounted is testable in isolation and reusable across versions.
Do:
import { createRouter } from 'nextrush';
export const usersRouter = createRouter();
usersRouter.get('/', (ctx) => ctx.json([{ id: 1, name: 'Ada' }]));
usersRouter.get('/:id', (ctx) => ctx.json({ id: ctx.params.id }));
usersRouter.post('/', (ctx) => {
ctx.status = 201;
ctx.json({ created: true });
});Result: a usersRouter with paths relative to its own root, ready to mount anywhere.
Mount feature routers under prefixes
Why: composing routers with mount rebases each one's paths under a prefix, so features stay decoupled from the URL layout.
Do:
import { createRouter } from 'nextrush';
const usersRouter = createRouter();
usersRouter.get('/:id', (ctx) => ctx.json({ id: ctx.params.id }));
const postsRouter = createRouter();
postsRouter.get('/:id', (ctx) => ctx.json({ postId: ctx.params.id }));
export const apiRouter = createRouter();
apiRouter.mount('/users', usersRouter); // → /users/:id
apiRouter.mount('/posts', postsRouter); // → /posts/:idResult: one apiRouter that owns the shape of the API, with feature routers plugged in.
Share middleware across a group
Why: an admin section shares one concern — an auth check. A group declares it once for the whole section instead of on every route.
Do:
import { createRouter } from 'nextrush';
import type { Middleware } from 'nextrush';
const requireAdmin: Middleware = async (ctx, next) => {
if (ctx.get('authorization') !== 'Bearer admin-token') {
ctx.status = 401;
return ctx.json({ error: 'Unauthorized' });
}
await next();
};
export const adminRouter = createRouter();
adminRouter.group('/admin', [requireAdmin], (admin) => {
admin.get('/dashboard', (ctx) => ctx.json({ page: 'dashboard' }));
admin.get('/settings', (ctx) => ctx.json({ page: 'settings' }));
});Result: every route inside the group runs requireAdmin first — add a route later and it is protected automatically.
Mount the API on the application
Why: keeping the version prefix at the application layer lets the same feature routers serve v1 today and v2 later, unchanged.
Do:
import { createApp, createRouter, listen } from 'nextrush';
const apiRouter = createRouter();
apiRouter.get('/health', (ctx) => ctx.json({ ok: true }));
const app = createApp();
app.route('/api/v1', apiRouter); // → /api/v1/health
listen(app, 8080);Result: the composed API is live under /api/v1.
Verify
Start the server, then confirm each route. Each line is the request, its expected status, and what success means:
curl localhost:8080/api/v1/users # 200 — feature router mounted correctly
curl localhost:8080/api/v1/posts # 200 — second feature router works
curl localhost:8080/api/v1/admin/dashboard # 401 — the group guard is active
curl -H "authorization: Bearer admin-token" \
localhost:8080/api/v1/admin/dashboard # 200 — credentials pass the guardIf the admin route returns 200 without the header, the guard is not wired — see Troubleshooting.
Production considerations
Security
Group middleware order is a boundary. Attach an auth guard to the group before the routes it protects, and register global auth before the router that mounts protected features. A protected route mounted before its guard is an open route.
Performance
Mounting and grouping are registration-time work — they add no per-request cost. Keep feature routers focused; the composed tree matches at the same speed regardless of how many routers you mount.
Reliability
Mount app.use(router.allowedMethods()) after the routes so a known path hit with an unregistered method returns 405, not 404. Two routers mounted at the same prefix that register the same method and path throw at startup, so a collision surfaces immediately.
Deployment
Keep version prefixes (/api/v1) at the application-mount layer, never baked into feature routers, so a router is reusable across versions. Feature routers hold no runtime-specific code, so the composed app deploys unchanged to any adapter.
Troubleshooting
Ordered most common first:
- Every route returns
404. Cause: the router was built but never mounted. Fix:app.route(prefix, router)(orapp.use(router.routes())). - A protected route returns
200without credentials. Cause: the guard runs after the route, or the group was mounted before global auth. Fix: attach the guard to the group, and register cross-cutting middleware before the routers it protects. Route conflictthrown at startup. Cause: two routers register the same method + path under the same mount. Fix: check your mount prefixes — two features mounted at the same prefix collide.
Common mistakes
- Baking the version prefix into feature routers. Why it happens: it feels natural to write the full path where the route lives. Fix: put
/api/v1at theapp.route()mount so the router stays version-agnostic. - Repeating middleware per route. Why it happens: each route is added on its own, so the guard is copied alongside it. Fix: a shared concern across a prefixed set is a
group, not three copies. - Mounting before guarding. Why it happens: routers are wired in the order features are built, not the order middleware must run. Fix: register cross-cutting middleware first, then mount the protected routers.
Key takeaways
- Build one router per feature; keep it unaware of where it is mounted.
- Mount routers instead of copying prefixes onto every path.
- Use a
groupfor middleware shared across a set of routes. - Keep API versioning at the application layer, not in feature routers.