Routers
import { routerFor } from '@theoven/core'import type { app } from '../app'
export const admin = routerFor<typeof app>({ prefix: '/admin', tags: ['admin'], auth: 'staff' })
admin.get('/users', (ctx) => ctx.db.select().from(users))admin.delete('/users/:id', { params: z.object({ id: z.uuid() }) }, (ctx) => remove(ctx.params.id))import { admin } from './routes/admin'
export const app = createApp().use(db(...)).use(auth(...)).use(admin)Every route in that file is mounted under /admin, tagged admin in the OpenAPI document, and
guarded by the staff policy — declared once.
What a router is
Section titled “What a router is”A container for routes, with no lifecycle of its own. It holds them until something mounts it, which is what makes it safe to build at module scope, mount twice, or export from a package.
app.use(router) registers everything it holds. After that the routes are ordinary routes:
validated, guarded, documented, and visible in oven routes.
Defaults
Section titled “Defaults”| Option | Applies |
|---|---|
prefix |
prepended to every path, and to every nested router’s prefix |
tags |
added to every route’s OpenAPI tags |
auth |
the requirement for every route in the group |
deps |
dependencies every route in the group resolves |
Deliberately small. A router says what its routes have in common; anything that varies per route belongs on the route, where the reader is already looking.
Tags accumulate, everything else is overridden
Section titled “Tags accumulate, everything else is overridden”const admin = router({ prefix: '/admin', tags: ['admin'] })admin.get('/users', { tags: ['users'] }, handler)// -> tags: ['admin', 'users']A route under /admin/users is plausibly both, and dropping either would lose a grouping someone
asked for. auth is not like that — two requirements are not more true than one, so the route’s
own wins.
const admin = router({ auth: 'staff' })admin.get('/reports', handler) // auth: 'staff'admin.get('/audit', { auth: 'owner' }, handler) // auth: 'owner'Dependencies accumulate too
Section titled “Dependencies accumulate too”const admin = routerFor<typeof app>({ prefix: '/admin', deps: { currentTenant } })
admin.get('/usage', (ctx) => report(ctx.deps.currentTenant))admin.get('/billing', { deps: { invoice } }, (ctx) => ({ tenant: ctx.deps.currentTenant, // from the router invoice: ctx.deps.invoice, // from the route}))Like tags and unlike auth, these accumulate with a route’s own — a router dependency and a
route dependency are both wanted, and replacing one with the other would leave a handler reading
something nothing resolved. On a name collision the route wins.
This is the equivalent of FastAPI’s APIRouter(dependencies=[...]), and it is a good way to
apply one guard to a group: a dependency that throws refuses every route under the router.
Nesting
Section titled “Nesting”const users = router({ prefix: '/users', tags: ['users'] })users.get('/:id', handler)
const admin = router({ prefix: '/admin', tags: ['admin'], auth: 'staff' }).use(users)
app.use(admin) // GET /admin/users/:id, tags ['admin','users'], auth 'staff'Prefixes join, tags accumulate down the tree, and a child’s own auth overrides its parent’s.
WebSocket routes
Section titled “WebSocket routes”const rooms = routerFor<typeof app>({ prefix: '/rooms', auth: true })
rooms.ws('/:id', { params: z.object({ id: z.uuid() }) }, { upgrade: (ctx) => ({ room: ctx.params.id, userId: ctx.user.id }), open: (socket) => socket.subscribe(socket.data.data.room), message: (socket, text) => app.publish(socket.data.data.room, String(text)),})
app.use(rooms) // GET /rooms/:id, guarded by the router's authThe router’s defaults apply, and here that matters more than anywhere else: a router declaring
auth guards its sockets too. A socket endpoint that quietly opted out of its group’s guard
would be exactly the unguarded back door app.ws() exists to prevent.
Middleware
Section titled “Middleware”const admin = router({ prefix: '/admin' })
admin.use(async (ctx, next) => { ctx.log.info('admin request') return next()})Scoped to the router’s prefix, and it wraps the router’s routes regardless of the order it was declared in relative to them — so a middleware written at the bottom of the file still runs first.
Prefixes are segment-aware: /admin covers /admin/users and never /administrators.
Typing
Section titled “Typing”routerFor<typeof app>() binds the app’s type so ctx.db, ctx.user and the rest are typed,
exactly as routesFor does and for
the same reason (D28): a router in its own module cannot see which bricks the app registered, and
TypeScript cannot relate sibling modules.
The import of app is type-only, so mounting the router in app.ts does not create a cycle.
import { router } from '@theoven/core'
const health = router({ prefix: '/_health' })health.get('/live', () => ({ ok: true }))Use plain router() when the routes touch no bricks — ctx is then just the core context.
Mounting twice
Section titled “Mounting twice”const api = router()api.get('/ping', () => 'pong')
app.use(router({ prefix: '/v1' }).use(api))app.use(router({ prefix: '/v2' }).use(api))Because a router holds routes rather than binding to an app, the same one can be mounted more than once. This is the mechanism behind versioned APIs, and behind a package that exports routes.
Routers or file-based routing?
Section titled “Routers or file-based routing?”Both, usually. They answer different questions.
| File routing | Routers | |
|---|---|---|
| Grouping | by directory | explicit, in code |
| Prefix | the directory path | prefix |
Shared auth / tags |
per file | once, for the group |
| Mountable twice | ✗ | ✓ |
| Shippable from a package | ✗ | ✓ |
Discoverable by reading ls |
✓ | you have to find the file |
File-based routing stays the default for an application’s own endpoints — the filesystem is a route table everyone can read. Reach for a router when you need something the filesystem cannot express: one shared guard over a group, a set of routes mounted under two prefixes, or routes shipped from a package.
Inspecting one
Section titled “Inspecting one”admin.size // routes it holds, nested ones includedadmin.prefix // '/admin'admin.collect() // { routes, middleware } — prefixes joined, defaults appliedcollect() is what app.use() calls. It is public so a package can see what it is about to
register, and so the merge rules are testable without an app.
Limitations
Section titled “Limitations”- No sub-application mounting. A router is routes, not a second app: it has no bricks, no
lifecycle and no error handler of its own. Mounting a whole
Appinside another — with the questions that raises about whose middleware and whose error handler run — is not supported. - A nested router’s dependencies are not typed for its own handlers — see above.
- No per-router error handling.
onErroris app-wide.
How it is verified
Section titled “How it is verified”The merge rules carry the risk, so they are tested without an app at all: tags accumulating,
auth overriding, auth: false surviving a guarded router, other schema keys untouched, and
prefixes joining through nesting.
Both of the subtle ones were confirmed to fail when broken — replacing tags instead of
accumulating, and merging auth with || so an explicit false is swallowed. That second one is
the reason the check reads 'auth' in schema.
Mounting is tested through a real app: every verb, prefix boundaries, validation still applying,
middleware scoping, the same router mounted under two prefixes, and the routes appearing in
routeTable().