- name
- stacks-router
- description
- Use when working with routing in a Stacks application - defining routes, HTTP methods, route groups, middleware, named routes, URL generation, request enhancement (Laravel-style input/query/file helpers), response helpers, error responses, route model binding, or rate limiting. Covers @stacksjs/router, routes/, and app/Routes.ts.
- license
- MIT
- compatibility
- Bun >= 1.3.0, TypeScript
- allowed-tools
- Read Edit Write Bash Grep Glob
# Stacks Router
Built on `@stacksjs/bun-router` with `ts-rate-limiter`.
## Key Paths
- Core package: `storage/framework/core/router/src/`
- Route files: `routes/` (api.ts, v1.ts, buddy.ts, users.ts)
- Route registry: `app/Routes.ts`
- Generated route manifest: `storage/framework/stx/routes.ts` (written by the dev server)
- Derived name types: `storage/framework/types/registries.d.ts`
- The maps they read: `storage/framework/auto-imports/{actions,listeners,policies,middleware,emails,routes}.ts`
## Route Definition
```typescript
import { route } from '@stacksjs/router'
route.get('/users', handler)
route.post('/users', handler)
route.put('/users/{id}', handler)
route.patch('/users/{id}', handler)
route.delete('/users/{id}', handler)
route.options('/users', handler)
route.health() // GET /health endpoint
```
### Redirects
Laravel's `Route::redirect` and `Route::permanentRedirect`:
```typescript
route.redirect('/here', '/there') // 302
route.redirect('/here', '/there', 307) // any of 301, 302, 303, 307, 308
route.permanentRedirect('/old', '/new') // 301
```
The incoming query string is carried onto the target unless the target names
its own. Group prefixes apply, and the call returns a chainable route.
A redirect for a page path goes in a root-mounted route file (`'web'` in
`app/Routes.ts`, or any entry with `prefix: ''`). The views server answers
page paths and never loads route files, so `buddy generate:types` writes those
redirects to `storage/framework/auto-imports/routes.ts` (`routeRedirects`) and
both `buddy dev` and `buddy serve` answer them before looking for a page: no
`config/server.ts` proxy rule and no hop to the API. Re-run
`generate:types` after adding one (CI's barrel check catches a stale map).
Exact paths only; a `{param}` redirect stays an API route. A rule in
`config/server.ts` `redirects` wins a clash.
### Zero-allocation static responses
Use `staticResponse()` only when the response is final at registration time and
must bypass the request pipeline entirely:
```typescript
route.staticResponse('GET', '/ready', new Response('{"ready":true}', {
headers: { 'content-type': 'application/json' },
}))
```
Bun serves that `Response` directly from its native route table. No handler,
middleware, request ID, CSRF check or cookie, rate limit, request context,
security or CORS header mutation, compression, or response formatting runs.
Supply the final status, headers, and body bytes yourself.
Static responses return the router, not a chainable route, so `.middleware()`,
`.name()`, `.skipCsrf()`, `.requireCsrf()`, and `.rateLimit()` cannot be attached
and silently bypassed. A static response inside a group with middleware or
`apiResponse: true` throws during registration. Prefix-only groups are valid.
Duplicate method and path registrations keep the first response.
### Chainable Methods
```typescript
route.get('/admin', handler)
.middleware('auth')
.name('admin.dashboard')
```
### Rate limits and the client address
`.rateLimit(max, 'minute' | seconds)` on a route and `await rateLimit(key, max,
{ identity? }).per('hour')` in an action count in the configured Redis cache
when `config/cache.ts` selects Redis (one budget across every worker and
instance), otherwise, and while Redis is unreachable (warned once), in process
memory. `useRateLimitStorage(storage | null)` installs any ts-rate-limiter
`StorageProvider` (call `resetRateLimiters()` after). Every `max`, the Throttle
middleware's and the login lockout's are multiplied by
`config.security.rateLimit.scale` (`STACKS_RATE_LIMIT_SCALE`, default 1):
raise it in a test suite instead of turning limits off.
The anonymous identity is `clientAddress(req)` from `@stacksjs/router`, which
shadows bun-router's export and believes forwarding headers only from
`config.security.trustedProxies` (unset: loopback, private and link-local
networks; `[]`: none). Set it to the load balancer's ranges when other hosts
share the private network. bun-router's own `request.ip()` and the server
package's maintenance allow-list still use the bun-router default.
### Route Groups
```typescript
route.group({ prefix: '/api/v1', middleware: ['auth', 'throttle'] }, () => {
route.get('/users', listUsers)
route.post('/users', createUser)
})
```
### Handler Types
- Function: `(req) => ...` - return a `Response`, or any value `formatResult`
handles: an object/array becomes JSON, a string becomes text, `null` becomes
204, a `ReadableStream` streams. `req.params` is narrowed to the path's own
placeholders, so `req.params.slugTypo` is a compile error rather than
`undefined` at runtime.
- Action string: `'Actions/CreateUser'` — auto-loads action, lazily
- Action object: an imported action, passed directly — see typed routes below
- Controller: `'Controllers/UserController@index'` — calls controller method
Generated Action registries keep their source `.ts` targets. For current
`app/Actions/` files, the loader uses a colocated regular `.js` file only when
the exact source target is absent. An existing source always wins, including
when its import fails. Nested Actions and app overrides follow the same rule.
Packaged defaults, controllers and middleware keep their own resolution.
This supports native app-build output; it does not qualify a complete server
image or Docker deployment.
## The strings are typed
Action paths, middleware aliases and route names are all checked at compile
time against what this application actually has, and nothing is maintained by
hand - or generated as a type.
Each of them is `keyof` over a map the RESOLVER reads:
`storage/framework/auto-imports/{actions,middleware,routes}.ts`, name to file,
written by `buddy generate` alongside the models and jobs barrels. So a name
that type-checks is a name that resolves; there is no second list to go stale.
`storage/framework/types/registries.d.ts` is where the derivation lives.
Middleware aliases need no map at all - `app/Middleware.ts` and the framework's
own are ordinary modules, and `defineMiddleware` keeps their literal keys.
```typescript
route.get('/login', 'Actions/Auth/LogniAction') // ✗ no such action
route.get('/admin', handler).middleware('atuh') // ✗ no such middleware alias
url('email.unsubscrbe', { token }) // ✗ no such route name
url('user.post', { id: 42 }) // params come from the path
```
The middleware one is the one that matters most: a typo'd alias used to serve
the route **without** the protection, silently.
Notes:
- Controllers stay a pattern (`'Controllers/X@method'`) — the method half is a
member name, not a filename.
- Negated (`'!auth'`) and parameterised (`'throttle:60,1'`) middleware forms are
both accepted.
- The maps refresh on `buddy generate`, `buddy generate:types` and dev-server
boot, and the staleness check watches the directories they are built from. A
map written before a file was added rejects code that is correct.
- `resource()` takes a BASE, and composes `Actions/<Base><Kind>Action` from it.
`route.resource('posts', 'Post')` → `Actions/PostIndexAction`, matching where
`buddy make:crud` writes. The base is checked against the actions that exist;
which of the five siblings you need depends on `only`/`except`, so that part
is settled when the route is hit.
### Path params arrive decoded
`/users/{name}` given `/users/caf%C3%A9` hands the handler `café`, and `%2F`
becomes a real `/`. Decoded exactly once, in bun-router — do NOT decode again in
an action or middleware: two passes turn `%2520` into a space, which is how a
filter that rejects `../` gets walked past. A malformed escape (`%ZZ`) passes
through raw rather than failing the request.
A decoded param can contain `/`, so anything joining one into a filesystem path
still has to sanitise. Decoding makes the value correct, not safe.
## Typed Routes (zero generation)
`route.get('/x', 'Actions/Foo')` resolves its action by a dynamic `import()` of a
string. Good for the runtime — lazy, hot-reload friendly — and completely opaque
to the compiler, so no client can be typed from it without a generation step.
`createTypedRouter()` registers through the same router while accumulating a
route map into its own type:
```typescript
import IndexAction from '../app/Actions/Project/IndexAction'
import StoreAction from '../app/Actions/Project/StoreAction'
import { createTypedRouter } from '@stacksjs/router'
export const api = createTypedRouter()
.get('/v1/projects', IndexAction)
.post('/v1/projects', StoreAction, { middleware: 'auth', rateLimit: { max: 10 } })
export type AppRoutes = typeof api
```
Any TypeScript consumer then gets full inference with **no CLI step**:
```typescript
import { createTypedClient } from '@stacksjs/router'
const client = createTypedClient<AppRoutes>({ baseUrl })
const projects = await client.get('/v1/projects') // typed from the action
```
Facts worth knowing before using it:
- **One runtime path.** A directly-registered action goes through the same
`wrapAction` as a string-registered one — validation, `authorize`, `before`,
`formatResult`, error reporting. Only the compile-time story differs.
- **Input from `validations`, output from `handle`'s return type.** An action
returning a `Response` is typed `unknown`; it took over the wire format.
- **Options are an argument**, not chained — chaining would return the route and
lose the accumulated type.
- **No `.group()`.** A runtime-only prefix makes every path type wrong; a
type-only prefix is a second place for the URL to live.
- **Both forms feed OpenAPI.** Directly-registered actions are reported by
`listRegisteredRoutes()`, so the generator reads their schema with no file
path to import.
- The builder, the client and the contract live in `@stacksjs/bun-router` and
are re-exported here. See the `stacks-api` skill for the full client story.
## Route Registry (app/Routes.ts)
```typescript
export default {
'api': 'api', // routes/api.ts → /api/* (auto-prefixed; see #1835)
'v1': { path: 'v1', prefix: 'v1' }, // routes/v1.ts → /v1/*
'admin': { path: 'admin', prefix: 'admin', middleware: ['auth'] }
} satisfies Record<string, string | RouteDefinition>
```
The registry is optional; `appRouteRegistry()` falls back to the API file
mounted at `/api`. App registrations precede default bundles and the first
duplicate method/path wins. Default bundles are selected explicitly or through
their feature gates; see `stacks-routes` for `STACKS_DEFAULT_ROUTES` and prefix
rules. Do not hard-code an assumption that every built-in route is mounted.
## Binding, signed access, and request lifecycle
`defineRouteModelBinding(param, resolver)` resolves a raw path value for policy
middleware. Explicit bindings take precedence over the optional fallback.
Unclaimed parameters stay raw; a claimed missing model is different from an
unclaimed parameter. Binding does not remove the need to validate a path key.
`signedUrl`, `signUrl`, `verifySignedUrl`, and `verifySignedUrlMiddleware`
support temporary signed access. Signing requires `APP_KEY` or the dedicated
secret. Verify method/URL/expiry according to the helper contract; changing a
signed URL is not equivalent to authorizing a row. Storage signatures are a
separate provider/delivery contract.
Native request context carries typed model/user data, query tracking, trace
IDs, route params, and hooks. `safePathParam`/`sanitizePathParam` protect path
segments, while numeric IDs need numeric validation as well. `stream` preserves
backpressure; do not eagerly collect a stream merely to attach JSON metadata.
Read `health`, `signed-url`, `route-model-binding`, `precognition`,
`request-context`, `after-response`, and `stream-backpressure` tests before
depending on the corresponding lifecycle behavior.
## Enhanced Request (Laravel-style)
### Input Methods
```typescript
req.get('name', 'default') // get input value
req.input('name', 'default') // alias
req.all() // all input
req.only(['name', 'email']) // specific fields
req.except(['password']) // all except
req.has('name') // exists?
req.has(['name', 'email']) // all exist?
req.hasAny(['name', 'email']) // any exist?
req.filled('name') // exists and not empty?
req.missing('name') // doesn't exist?
req.query // query parameters object
```
### Type Conversion
```typescript
req.string('name', '')
req.integer('page', 1)
req.float('price', 0.0)
req.boolean('active', false)
req.array('tags')
```
### File Handling
```typescript
const file = req.file('avatar') // UploadedFile | null
const files = req.getFiles('images') // UploadedFile[]
req.hasFile('avatar') // boolean
const all = req.allFiles() // Record<string, UploadedFile[]>
```
### Authentication
```typescript
const user = await req.user()
const token = await req.userToken()
await req.tokenCan('create-posts')
await req.tokenCant('delete-users')
```
## Response Helpers
```typescript
import { response } from '@stacksjs/router'
response.json(data, { status: 200 })
response.created(data) // 201
response.noContent() // 204
response.badRequest(data) // 400
response.unauthorized() // 401
response.forbidden() // 403
response.notFound() // 404
response.error() // 500
response.redirect(url, 302)
response.text('hello')
response.html('<h1>Hi</h1>')
```
## Error Responses
```typescript
createErrorResponse(error, request, options?)
createMiddlewareErrorResponse(error, request)
createValidationErrorResponse(errors, request)
createNotFoundResponse(path, request)
```
Error response body:
```typescript
{ error: string, message: string, status: number, timestamp: string, details?: Record<string, unknown> }
```
## Request Context
```typescript
import { getCurrentRequest, setCurrentRequest, runWithRequest, request } from '@stacksjs/router'
// Async context management
const req = getCurrentRequest()
runWithRequest(req, async () => {
// `request` proxy available here
View on GitHub