Skip to content

Convention-based Routing

Controllers are where you handle HTTP requests. Each controller is a class decorated with @Controller that defines one or more route handler methods. Stratal offers two routing approaches: convention-based routing (covered on this page), where method names map directly to HTTP verbs and paths, and HTTP method decorators, where you use explicit @Get, @Post, etc. decorators for full control over paths and methods.

Decorate a class with @Controller and implement the IController interface:

import { Controller, IController, Route, RouterContext } from 'stratal/router'
import { z } from 'stratal/validation'
@Controller('/api/users')
export class UsersController implements IController {
@Route({
response: z.object({
users: z.array(z.object({ id: z.string(), name: z.string() })),
}),
})
index(ctx: RouterContext) {
return ctx.json({ users: [] })
}
}

The @Controller decorator takes two arguments:

  1. route (required) — the base path for all routes in this controller.
  2. options (optional) — an object with:
OptionTypeDescription
tagsstring[]OpenAPI tags applied to every route in the controller
securitySecurityScheme[]Security schemes applied to every route
hideFromDocsbooleanExclude all routes from the OpenAPI specification
versionstring | string[] | typeof VERSION_NEUTRALAPI version(s) for this controller (see Versioning)
namestringName prefix for routes in this controller (used for URL generation)
domainstringDomain pattern to restrict this controller to (e.g., '{tenant}.myapp.com'). See Domain Routing.
@Controller('/api/admin', {
tags: ['Admin'],
security: ['bearerAuth'],
hideFromDocs: true,
})
export class AdminController implements IController {
// ...
}

Stratal maps method names on the IController interface to HTTP verbs and paths automatically. You only implement the methods you need:

MethodHTTP verbPathDefault status code
index()GET/route200
show()GET/route/:id200
create()POST/route201
update()PUT/route/:id200
patch()PATCH/route/:id200
destroy()DELETE/route/:id200

For a controller at /api/users, defining show() automatically registers GET /api/users/:id. Defining create() registers POST /api/users with a 201 status code.

The @Route decorator configures individual route methods. It accepts a RouteConfig object:

import { Controller, IController, Route, RouterContext } from 'stratal/router'
import { z } from 'stratal/validation'
@Controller('/api/users')
export class UsersController implements IController {
@Route({
body: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
response: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
summary: 'Create a new user',
description: 'Registers a new user account',
tags: ['Users'],
})
create(ctx: RouterContext) {
// body is validated before this method runs
// ...
}
@Route({
params: z.object({ id: z.string().uuid() }),
response: z.object({
id: z.string(),
name: z.string(),
}),
})
show(ctx: RouterContext) {
const id = ctx.param('id')
// ...
}
}

The full RouteConfig interface:

PropertyTypeDescription
bodyRouteBodyValidates the request body. Accepts a Zod schema (defaults to application/json) or a { schema, contentType? } object for custom content types.
paramsZodObjectValidates path parameters
queryZodObjectValidates query string parameters
responseRouteResponseSchema for the response body. Accepts a Zod schema (defaults to application/json) or a { schema, description?, contentType? } object.
tagsstring[]OpenAPI tags for this route
securitySecurityScheme[]Security schemes for this route
summarystringShort OpenAPI summary
descriptionstringDetailed OpenAPI description
hideFromDocsbooleanExclude this route from the OpenAPI spec
statusCodenumberOverride the default success status code

Every route handler receives a RouterContext instance as its first argument. It provides methods to read the request and build the response:

// Path parameter
const id = ctx.param('id')
// Query string parameter
const page = ctx.query('page')
// All query parameters as an object
const filters = ctx.query()
// Request header
const auth = ctx.header('Authorization')
// Parsed request body
const data = await ctx.body<CreateUserDto>()
// JSON response
return ctx.json({ id: '1', name: 'Alice' })
// JSON response with custom status
return ctx.json({ id: '1' }, 201)
// Plain text
return ctx.text('OK')
// HTML
return ctx.html('<h1>Hello</h1>')
// Redirect
return ctx.redirect('/api/users/1')
return ctx.redirect('/login', 302)

RouterContext provides three methods for streaming data to the client:

stream(callback, onError?) — Binary / generic streaming

Section titled “stream(callback, onError?) — Binary / generic streaming”
@Get('/download', { response: { schema: z.any(), contentType: 'application/octet-stream' } })
download(ctx: RouterContext) {
return ctx.stream(async (stream) => {
await stream.write(new Uint8Array([72, 101, 108, 108, 111]))
})
}

streamText(callback, onError?) — Text streaming

Section titled “streamText(callback, onError?) — Text streaming”
@Get('/text', { response: { schema: z.any(), contentType: 'text/plain' } })
text(ctx: RouterContext) {
return ctx.streamText(async (stream) => {
await stream.write('hello ')
await stream.write('world')
})
}

streamSSE(callback, onError?) — Server-Sent Events

Section titled “streamSSE(callback, onError?) — Server-Sent Events”
@Get('/events', { response: { schema: z.any(), contentType: 'text/event-stream' } })
events(ctx: RouterContext) {
return ctx.streamSSE(async (stream) => {
await stream.writeSSE({ data: 'hello', event: 'message', id: '1' })
})
}

All three methods accept an optional onError callback that is invoked if the streaming function throws:

return ctx.stream(
() => {
throw new StreamError()
},
async (err, stream) => {
await stream.writeln(err.message)
await stream.close()
}
)
// Get and set the request locale
const locale = ctx.getLocale()
ctx.setLocale('fr')
// Access the DI container for the current request
const container = ctx.getContainer()
const service = container.resolve(MyService)

If the six convention-based methods do not fit your needs, you can implement handle() for full control over routing. When handle() is defined, it receives all requests that match the controller’s base path and Stratal skips convention-based routing for that controller.

import { Controller, IController, RouterContext } from 'stratal/router'
@Controller('/api/health')
export class HealthController implements IController {
handle(ctx: RouterContext) {
return ctx.json({ status: 'ok' })
}
}

Guards control whether a request is allowed to proceed. Apply them at the controller level or on individual methods using the @UseGuards decorator:

import { Controller, IController, UseGuards, Route, RouterContext } from 'stratal/router'
import { AuthGuard } from './auth.guard'
import { RolesGuard } from './roles.guard'
@Controller('/api/orders')
@UseGuards(AuthGuard) // applies to all methods
export class OrdersController implements IController {
@Route({ response: orderListSchema })
index(ctx: RouterContext) {
// AuthGuard runs before this method
return ctx.json({ orders: [] })
}
@UseGuards(RolesGuard) // applies only to this method (in addition to controller guards)
@Route({ response: orderSchema })
destroy(ctx: RouterContext) {
// AuthGuard and RolesGuard both run before this method
// ...
}
}

Controller-level guards run first, followed by method-level guards. If any guard returns false, Stratal rejects the request before the handler executes.

For a complete guide on writing guards, see the Guards guide.

Stratal includes built-in Quarry CLI commands for inspecting and calling your routes.

Use route:list to see all registered routes:

Terminal window
npx quarry route:list

This outputs a table with the following columns:

ColumnDescription
MethodHTTP method (GET, POST, etc.) or WS for WebSocket routes
PathThe full route path
ActionThe handler name (controller method)
TypeHTTP or WS

Filter by method or path:

Terminal window
# Only GET routes
npx quarry route:list --method GET
# Routes containing "users"
npx quarry route:list --path users
# Combine filters
npx quarry route:list --method POST --path /api/admin

Use the api command to call any route from the terminal:

Terminal window
# GET request (default)
npx quarry api /api/users
# POST with JSON body
npx quarry api /api/users --method POST --data '{"name":"Alice","email":"alice@example.com"}'
# With custom headers and query parameters
npx quarry api /api/users --header "Authorization:Bearer token123" --query "page=2" --query "limit=10"
OptionDescription
--methodHTTP method (defaults to GET)
--dataJSON request body
--headerHeader as Key:Value (repeatable)
--queryQuery parameter as key=value (repeatable)

The response displays the status, headers, and formatted body.

Modules can implement the RouteConfigurable interface to configure routing with a fluent builder API. This gives you control over route prefixes, domain patterns, middleware, versioning, and grouping — all scoped to the module’s controllers.

import { Module, type RouteConfigurable, type Router } from 'stratal/module'
import { AuthMiddleware } from './auth.middleware'
@Module({
controllers: [UsersController, AdminController],
})
export class ApiModule implements RouteConfigurable {
configureRoutes(router: Router) {
router
.name('api.')
.middleware(AuthMiddleware)
.group([UsersController], (r) => {
r.prefix('/users').name('users.')
})
.group([AdminController], (r) => {
r.prefix('/admin').name('admin.').hideFromDocs()
})
}
}
MethodDescription
prefix(path, params?)Set a path prefix for all routes. Optionally pass a Zod schema to validate path parameters.
domain(pattern)Restrict routes to a domain pattern (e.g., '{tenant}.myapp.com'). See Domain Routing.
name(prefix)Add a name prefix to all routes (e.g., 'api.' makes index become 'api.index').
middleware(...classes)Apply middleware to all routes in scope.
version(v)Set the API version (string or string array).
hideFromDocs(hide?)Hide routes from the OpenAPI spec.
use(...classes)Register global middleware (root router only).
group(controllers, callback)Create a sub-group with its own prefix, name, middleware, etc.

When routes have names (from router.name() or @Route({ name: '...' })), you can generate URLs using ctx.route():

@Controller('/users')
export class UsersController implements IController {
@Route({ name: 'users.show' })
show(ctx: RouterContext) {
const id = ctx.param('id')
return ctx.json({ id })
}
@Route()
index(ctx: RouterContext) {
// Generate a URL to the show route
const url = ctx.route('users.show', { id: '42' })
// → '/users/42'
return ctx.json({ users: [], links: { detail: url } })
}
}

Pass { absolute: true } to generate a full URL (scheme + host) using the current request’s origin:

const url = ctx.route('users.show', { id: '42' }, { absolute: true })
// → 'https://example.com/users/42'

For type-safe route names, generate types with the CLI:

Terminal window
npx quarry route:types

This creates a src/stratal.d.ts file that provides autocomplete for route names and validates parameters.

Use the standalone route() function or inject the Uri service when you need URL generation from a queue consumer, cron job, or service that doesn’t receive a RouterContext:

import { route } from 'stratal/router'
const url = route('users.show', { id: '42' })

The Uri service exposes the same primitives plus request-aware helpers like current(), full(), previous(), to(), query(), and sticky parameter defaults() — useful when you want to share a default like locale across many route() calls in a single request. Resolve it from the request container:

import { ROUTER_TOKENS, type Uri } from 'stratal/router'
const uri = ctx.getContainer().resolve<Uri>(ROUTER_TOKENS.Uri)
uri.defaults({ locale: 'en' })
uri.route('posts.index') // auto-fills :locale param

By default Stratal accepts both /users and /users/ for the same route. To enforce a canonical form, pass a trailingSlash option to the Stratal constructor:

import 'reflect-metadata'
import { Stratal } from 'stratal'
import { AppModule } from './app.module'
export default new Stratal({
module: AppModule,
trailingSlash: 'always', // or 'never' | 'ignore'
})
ModeBehaviour
'ignore' (default)Both /users and /users/ match. URL generation leaves paths as-is.
'always'Requests without a trailing slash redirect (308) to the trailing-slash form. URL generation appends /.
'never'Requests with a trailing slash redirect (308) to the non-trailing form. URL generation strips /.

The redirect uses HTTP 308 so request bodies survive on POST, PUT, and PATCH. Paths whose final segment contains a dot (e.g. /openapi.json) and the root / are always passed through unchanged.

ctx.route(), the standalone route() function, and the Uri service all apply the configured mode automatically, so generated URLs stay consistent with the redirect behaviour.