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, Route, RouterContext } from 'stratal/router'
import { UseGuards } from 'stratal/guards'
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 } from 'stratal/module'
import type { RouteConfigurable, Router } from 'stratal/router'
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

When a route carries a :locale path segment, passing a locale param prefixes the generated URL automatically. This is the building block for SEO needs like canonical links, locale switchers, and hreflang alternates:

// Route path: '/{locale}/posts'
uri.route('posts.index', { locale: 'fr' })
// → '/fr/posts'

Whether the default locale is prefixed follows your i18n path configuration. Set a sticky locale default once and every later route() call fills the segment for you:

uri.defaults({ locale: ctx.getLocale() })
uri.route('posts.index') // → '/fr/posts'
uri.route('posts.show', { id: '42' }) // → '/fr/posts/42'

To compute locale path variants directly (for emitting hreflang <link> tags or a canonical URL across every supported locale), inject the LocaleUrlService:

import { ROUTER_TOKENS, type LocaleUrlService } from 'stratal/router'
const localeUrl = ctx.getContainer().resolve<LocaleUrlService>(ROUTER_TOKENS.LocaleUrlService)
localeUrl.applyPrefix('/posts', 'fr') // → '/fr/posts' (respects prefixDefaultLocale)
localeUrl.stripPrefix('/fr/posts') // → '/posts'
localeUrl.shouldPrefix('en') // false when 'en' is the unprefixed default

A typical hreflang block iterates supported locales, applying the prefix to the current path and pairing each with an absolute URL:

const path = uri.current()
const alternates = locales.map((locale) => ({
hreflang: locale,
href: uri.to(localeUrl.applyPrefix(localeUrl.stripPrefix(path), locale), undefined, { absolute: true }),
}))

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 { 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. The Location header is path-relative (no scheme or host), which avoids mixed-content blocks behind an HTTPS-terminating proxy. 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.

Some paths have a canonical form dictated by an external party: an OAuth redirect URI registered with an identity provider is matched byte for byte, so neither slash form may be rewritten. Pass { mode, exclude } instead of a bare mode to exempt those paths:

export default new Stratal({
module: AppModule,
trailingSlash: {
mode: 'always',
exclude: ['/auth/oauth2', /^\/webhooks\//],
},
})

Excluded paths are never redirected (no 308) and never rewritten by URL generation. Both slash forms are served exactly as requested.

Pattern typeMatching behaviour
stringSegment-aware prefix. '/auth/oauth2' exempts /auth/oauth2 and /auth/oauth2/callback/x, but not /auth/oauth2-other.
RegExpTested against both forms of the pathname (with and without the trailing slash), so anchoring to either form exempts both.

Exclusions match in route space. When path-based locale detection is active, a leading locale segment is stripped before matching, so '/callback' also exempts /fr/callback.