Routes
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 throws invalid 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 separate router.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-side urlFor(...) 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.children use .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 same routes.ts as 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.ready fires before router.commit(), so it does not rescue transmit.authorize.
Repo refs
- Resourceful + verb routes with numeric matchers:
app/users/routes.ts.
- Verb-only routes:
app/notifications/routes.ts.
Anti-patterns
- ❌ Numeric
:id without .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 of start/*.ts — the router isn't committed yet at preload time.
- ❌ Renaming params to
id1 / id2 in 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]]
1---2name: routes3description: Route definitions in an AdonisJS module. Prefer `router.resource(...)` for CRUD verbs; put verb-only actions (activate, publish, finalize) as separate `router.post/get/delete(...)`. Always pin numeric params with `router.matchers.number()` — otherwise a non-numeric URL falls through to the controller and blows up on the ORM. Trigger on: "add route", "register route", "resource route", "matcher", ":id 500", "route naming".4license: MIT5---67# Routes89Each 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.1011## Rules12131. **Numeric params get `router.matchers.number()`**. Without it, a non-numeric URL reaches the controller, `Number('foo') → NaN`, and Postgres throws `invalid input syntax for type integer` — the response is a 500. With the matcher the router 404s upstream, before boot.142. **CRUD verbs go through `router.resource()`**. Custom verb actions (activate, publish, finalize) are separate `router.post(...)` in the same file, not extra methods on the resource controller.153. **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-side `urlFor(...)` used inside jobs and listeners. Both derive the URL from the name; the name is a contract.164. **Param names match across parents**: for a nested resource named `parents.children` use `.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.175. **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.1819## Reference shape2021```ts22router23 .group(() => {24 // Top-level resource25 router26 .resource('/entities', EntitiesController)27 .only(['index', 'create', 'store', 'show'])28 .where('id', router.matchers.number())29 .as('entities')3031 // Verb action on an entity32 router33 .post('/entities/:id/publish', [EntitiesController, 'publish'])34 .where('id', router.matchers.number())35 .as('entities.publish')3637 // Nested resource — rename the parent segment + pin every numeric id38 router39 .resource('parents.children', ChildrenController)40 .only(['index', 'store', 'destroy'])41 .params({ parents: 'parent_id' })42 .where('parent_id', router.matchers.number())43 .where('id', router.matchers.number())4445 // Verb action inside the nested resource46 router47 .post('/parents/:parent_id/children/:id/approve', [ChildrenController, 'approve'])48 .where('parent_id', router.matchers.number())49 .where('id', router.matchers.number())50 .as('parents.children.approve')51 })52 .middleware(middleware.auth())53```5455## Choosing verb vs resource5657- `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.58- `router.post/get/delete(...)` with `.as(...)` when the action does not fit a CRUD verb: activate, publish, finalize, invite, impersonate. Put them in the same `routes.ts` as the resource they belong to.5960## Hardcoding a route pattern6162Some 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.6364Two options, in order of preference:65661. **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.672. **Defer with `app.ready(...)`.** Only works when the pattern is needed at request-handling time. `app.ready` fires before `router.commit()`, so it does **not** rescue `transmit.authorize`.6869## Repo refs7071- Resourceful + verb routes with numeric matchers: `app/users/routes.ts`.72- Verb-only routes: `app/notifications/routes.ts`.7374## Anti-patterns7576- ❌ Numeric `:id` without `.where('id', router.matchers.number())` — 500 on any non-numeric URL.77- ❌ Verb action stuffed into the resource controller as an extra method — pollutes route naming and forces `.only([...])` to grow.78- ❌ `router.findOrFail(...)` at the top level of `start/*.ts` — the router isn't committed yet at preload time.79- ❌ Renaming params to `id1` / `id2` in nested resources — URL clients read semantic names (`parent_id`, `child_id`).80- ❌ One giant middleware chain on each individual route instead of a `router.group(...).middleware(...)` block.8182## Related skills8384[[module-scaffolding]] · [[crud]] · [[actions-events]] · [[notifications]] · [[testing]]