| 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)
- Generated action paths:
storage/framework/types/actions.d.ts
Route Definition
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()
Chainable Methods
route.get('/admin', handler)
.middleware('auth')
.name('admin.dashboard')
Route Groups
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
The strings are typed (run buddy generate:types)
Action paths, middleware aliases and route names are all checked at compile
time against what this application actually has. buddy generate:types
discovers them and writes them into the router's type registry
(storage/framework/types/actions.d.ts); nothing is maintained by hand.
route.get('/login', 'Actions/Auth/LogniAction')
route.get('/admin', handler).middleware('atuh')
url('email.unsubscrbe', { token })
url('user.post', { id: 42 })
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.
- Regenerate after adding an action, a middleware alias, or a
.name(). A stale
file 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:
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:
import { createTypedClient } from '@stacksjs/router'
const client = createTypedClient<AppRoutes>({ baseUrl })
const projects = await client.get('/v1/projects')
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)
export default {
'api': 'api',
'v1': { path: 'v1', prefix: 'v1' },
'admin': { path: 'admin', prefix: 'admin', middleware: ['auth'] }
} satisfies Record<string, string | RouteDefinition>
Enhanced Request (Laravel-style)
Input Methods
req.get('name', 'default')
req.input('name', 'default')
req.all()
req.only(['name', 'email'])
req.except(['password'])
req.has('name')
req.has(['name', 'email'])
req.hasAny(['name', 'email'])
req.filled('name')
req.missing('name')
req.query
Type Conversion
req.string('name', '')
req.integer('page', 1)
req.float('price', 0.0)
req.boolean('active', false)
req.array('tags')
File Handling
const file = req.file('avatar')
const files = req.getFiles('images')
req.hasFile('avatar')
const all = req.allFiles()
Authentication
const user = await req.user()
const token = await req.userToken()
await req.tokenCan('create-posts')
await req.tokenCant('delete-users')
Response Helpers
import { response } from '@stacksjs/router'
response.json(data, { status: 200 })
response.created(data)
response.noContent()
response.badRequest(data)
response.unauthorized()
response.forbidden()
response.notFound()
response.error()
response.redirect(url, 302)
response.text('hello')
response.html('<h1>Hi</h1>')
Error Responses
createErrorResponse(error, request, options?)
createMiddlewareErrorResponse(error, request)
createValidationErrorResponse(errors, request)
createNotFoundResponse(path, request)
Error response body:
{ error: string, message: string, status: number, timestamp: string, details?: Record<string, unknown> }
Request Context
import { getCurrentRequest, setCurrentRequest, runWithRequest, request } from '@stacksjs/router'
const req = getCurrentRequest()
runWithRequest(req, async () => {
})
Middleware
import { Middleware } from '@stacksjs/router'
const logger = new Middleware({
name: 'logger',
priority: 5,
handle: async (request: EnhancedRequest) => {
console.log(`${request.method} ${request.url}`)
}
})
route.use(logger)
Available Middleware Aliases (from app/Middleware.ts)
maintenance, auth, guest, api, team, logger, abilities, can, throttle, local, development, staging, production, env.local, env.development, env.staging, env.production, role, permission, verified
Query Tracking
trackQuery(query, time?, connection?)
clearTrackedQueries()
clearMiddlewareCache()
Default API Routes (routes/api.ts)
Auth
POST /login, POST /register, POST /auth/refresh, POST /auth/token
GET /auth/tokens, DELETE /auth/tokens/{id} (auth)
GET /me, POST /logout (auth)
Email
POST /api/email/subscribe, GET /api/email/unsubscribe
AI
POST /ai/ask, POST /ai/summary
CMS
/cms/posts/*, /cms/authors/*, /cms/categories/*, /cms/tags/*, /cms/comments/*
Commerce
/commerce/products/*, /commerce/orders/*, /commerce/customers/*, /shipping/*
Monitoring
Health
GET /health — returns status, uptime, memory, PID, Bun version
Server Integration
import { serve, serverResponse } from '@stacksjs/router'
await serve({ port: 3000 })
const response = await serverResponse(request)
URL Generation
import { url } from '@stacksjs/router'
url('admin.dashboard')
url('user.show', { id: 42 })
Gotchas
- Routes use
@stacksjs/bun-router under the hood
- Rate limiting via
ts-rate-limiter is built into the router
- Route registry in
app/Routes.ts maps file names to URL prefixes
- String-based handlers (
'Actions/MyAction') are dynamically imported
- Middleware priority: lower number = runs first
- The
request proxy uses AsyncLocalStorage for context — must be inside runWithRequest()
- EnhancedRequest extends the native Bun Request with Laravel-style helpers
- File uploads return
UploadedFile objects with metadata
- Query tracking is for debug/profiling — call
clearTrackedQueries() to free memory
- The dev server writes a route manifest to
storage/framework/stx/routes.ts (stx's stateDir, set in config/ui.ts); it is a build artifact, not a file to edit
- The health endpoint returns uptime, memory, PID, and Bun version info