Each module owns its routes.ts. The file gets discovered by the preload wired in [[module-scaffolding]] — there is no autoloader. Routes have three loads to bear: they're the source of truth for URL generation (typed clients like Tuyau and the server-side URL builder read the named routes), for the middleware chain that guards the endpoint, and for the param typing that keeps garbage input out of the ORM.
Rules
- Numeric params get
router.matchers.number(). Without it, a non-numeric URL reaches the controller,Number('foo') → NaN, and Postgres throwsinvalid input syntax for type integer— the response is a 500. With the matcher the router 404s upstream, before boot. - CRUD verbs go through
router.resource(). Custom verb actions (activate, publish, finalize) are separaterouter.post(...)in the same file, not extra methods on the resource controller. - Route names follow the URL hierarchy:
parents.children.action. Named routes are the single source of truth for both the typed frontend URL client and the server-sideurlFor(...)used inside jobs and listeners. Both derive the URL from the name; the name is a contract. - Param names match across parents: for a nested resource named
parents.childrenuse.params({ parents: 'parent_id' })and pin every id:.where('parent_id', router.matchers.number()).where('id', router.matchers.number()). Snake_case, semantic, matching what the URL client expects. - Group by shared middleware. Put every route that shares the same auth/middleware stack inside one
router.group(() => {...}).middleware(...)block. Public routes live outside, guarded ones inside.
Reference shape
router
.group(() => {
// Top-level resource
router
.resource('/entities', EntitiesController)
.only(['index', 'create', 'store', 'show'])
.where('id', router.matchers.number())
.as('entities')
// Verb action on an entity
router
.post('/entities/:id/publish', [EntitiesController, 'publish'])
.where('id', router.matchers.number())
.as('entities.publish')
// Nested resource — rename the parent segment + pin every numeric id
router
.resource('parents.children', ChildrenController)
.only(['index', 'store', 'destroy'])
.params({ parents: 'parent_id' })
.where('parent_id', router.matchers.number())
.where('id', router.matchers.number())
// Verb action inside the nested resource
router
.post('/parents/:parent_id/children/:id/approve', [ChildrenController, 'approve'])
.where('parent_id', router.matchers.number())
.where('id', router.matchers.number())
.as('parents.children.approve')
})
.middleware(middleware.auth())
Choosing verb vs resource
router.resource(...)when the endpoint fits a standard CRUD verb:index,create,store,show,edit,update,destroy. Trim with.only([...])to what the controller actually exposes.router.post/get/delete(...)with.as(...)when the action does not fit a CRUD verb: activate, publish, finalize, invite, impersonate. Put them in the sameroutes.tsas the resource they belong to.
Hardcoding a route pattern
Some server-side callers need the pattern — the raw template with :params, not a filled URL. Example: transmit.authorize('/pattern/:x/...', ...) inside start/transmit.ts. Because that file executes during preload, before Server.boot() calls router.commit(), doing router.findOrFail(name).pattern there throws.
Two options, in order of preference:
- Hardcode the pattern + add a spec that guards it. Export the pattern as a constant, and write a functional test asserting the constant equals
router.findOrFail(name).pattern. If the route ever renames, the test breaks before deploy. - Defer with
app.ready(...). Only works when the pattern is needed at request-handling time.app.readyfires beforerouter.commit(), so it does not rescuetransmit.authorize.
Repo refs
- Resourceful + verb routes with numeric matchers:
app/users/routes.ts. - Verb-only routes:
app/notifications/routes.ts.
Anti-patterns
- ❌ Numeric
:idwithout.where('id', router.matchers.number())— 500 on any non-numeric URL. - ❌ Verb action stuffed into the resource controller as an extra method — pollutes route naming and forces
.only([...])to grow. - ❌
router.findOrFail(...)at the top level ofstart/*.ts— the router isn't committed yet at preload time. - ❌ Renaming params to
id1/id2in nested resources — URL clients read semantic names (parent_id,child_id). - ❌ One giant middleware chain on each individual route instead of a
router.group(...).middleware(...)block.
Related skills
[[module-scaffolding]] · [[crud]] · [[actions-events]] · [[notifications]] · [[testing]]