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
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
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 aResponse, or any valueformatResulthandles: an object/array becomes JSON, a string becomes text,nullbecomes 204, aReadableStreamstreams.req.paramsis narrowed to the path's own placeholders, soreq.params.slugTypois a compile error rather thanundefinedat 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
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.
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:typesand 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 composesActions/<Base><Kind>Actionfrom it.route.resource('posts', 'Post')→Actions/PostIndexAction, matching wherebuddy make:crudwrites. The base is checked against the actions that exist; which of the five siblings you need depends ononly/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') // typed from the action
Facts worth knowing before using it:
- One runtime path. A directly-registered action goes through the same
wrapActionas a string-registered one — validation,authorize,before,formatResult, error reporting. Only the compile-time story differs. - Input from
validations, output fromhandle's return type. An action returning aResponseis typedunknown; 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-routerand are re-exported here. See thestacks-apiskill for the full client story.
Route Registry (app/Routes.ts)
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>
Enhanced Request (Laravel-style)
Input Methods
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
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') // UploadedFile | null
const files = req.getFiles('images') // UploadedFile[]
req.hasFile('avatar') // boolean
const all = req.allFiles() // Record<string, UploadedFile[]>
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) // 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
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'
// Async context management
const req = getCurrentRequest()
runWithRequest(req, async () => {
// `request` proxy available here
})
Middleware
import { Middleware } from '@stacksjs/router'
const logger = new Middleware({
name: 'logger',
priority: 5, // lower = runs first, default 10
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/tokenGET /auth/tokens,DELETE /auth/tokens/{id}(auth)GET /me,POST /logout(auth)
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
/monitoring/errors/*
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') // '/admin/dashboard'
url('user.show', { id: 42 }) // '/users/42'
Gotchas
- Routes use
@stacksjs/bun-routerunder the hood - Rate limiting via
ts-rate-limiteris built into the router - Route registry in
app/Routes.tsmaps file names to URL prefixes - String-based handlers (
'Actions/MyAction') are dynamically imported - Middleware priority: lower number = runs first
- The
requestproxy uses AsyncLocalStorage for context — must be insiderunWithRequest() - EnhancedRequest extends the native Bun Request with Laravel-style helpers
- File uploads return
UploadedFileobjects 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'sstateDir, set inconfig/ui.ts); it is a build artifact, not a file to edit - The health endpoint returns uptime, memory, PID, and Bun version info