Skip to content

Route Conventions

Stratal uses convention-based routing by default. The name of a controller method determines the HTTP verb, URL path, and success status code for that route. This keeps your controllers declarative and removes the need for manual route registration. For an alternative approach using explicit decorators, see HTTP Method Decorators.

When you implement a method on a controller, Stratal derives the HTTP method and path suffix automatically:

Method nameHTTP verbPath suffixSuccess status
index()GET(none)200
show()GET/:id200
create()POST(none)201
update()PUT/:id200
patch()PATCH/:id200
destroy()DELETE/:id200

The final URL is the controller’s base path plus the path suffix. For a controller registered at /api/users:

MethodFull path
index()GET /api/users
show()GET /api/users/:id
create()POST /api/users
update()PUT /api/users/:id
patch()PATCH /api/users/:id
destroy()DELETE /api/users/:id

You only implement the methods your resource needs. If you only need listing and creation, define index() and create() and leave the rest out.

Here is a controller that implements all six conventional methods:

import { Controller, IController, Route, RouterContext } from 'stratal/router'
import { z } from 'stratal/validation'
import {
createUserSchema,
updateUserSchema,
userListSchema,
userResponseSchema,
} from './users.schemas'
@Controller('/api/users', { tags: ['Users'] })
export class UsersController implements IController {
@Route({
response: userListSchema,
summary: 'List all users',
description: 'Returns a list of all registered users.',
})
index(ctx: RouterContext) {
return ctx.json({ data: [] })
}
@Route({
params: z.object({ id: z.string() }),
response: userResponseSchema,
summary: 'Get user by ID',
})
show(ctx: RouterContext) {
const id = ctx.param('id')
return ctx.json({ data: { id, name: 'Alice' } })
}
@Route({
body: createUserSchema,
response: userResponseSchema,
summary: 'Create a user',
})
async create(ctx: RouterContext) {
const body = await ctx.body<{ name: string; email: string }>()
return ctx.json({ data: { id: '1', ...body } }, 201)
}
@Route({
params: z.object({ id: z.string() }),
body: updateUserSchema,
response: userResponseSchema,
summary: 'Replace a user',
})
async update(ctx: RouterContext) {
const body = await ctx.body<{ name: string; email: string }>()
return ctx.json({ data: { id: ctx.param('id'), ...body } })
}
@Route({
params: z.object({ id: z.string() }),
body: updateUserSchema,
response: userResponseSchema,
summary: 'Partially update a user',
})
async patch(ctx: RouterContext) {
const body = await ctx.body<Partial<{ name: string; email: string }>>()
return ctx.json({ data: { id: ctx.param('id'), ...body } })
}
@Route({
params: z.object({ id: z.string() }),
response: z.object({ success: z.boolean() }),
summary: 'Delete a user',
})
destroy(ctx: RouterContext) {
return ctx.json({ success: true })
}
}

Every conventional method needs a @Route decorator to be included in the OpenAPI spec. The decorator accepts a RouteConfig object with these properties:

PropertyTypeRequiredDescription
responseZodType or { schema, description }YesThe success response schema.
bodyZodTypeNoRequest body schema (for create, update, patch).
paramsZodObjectNoURL parameter schema (for show, update, patch, destroy).
queryZodObjectNoQuery parameter schema.
summarystringNoShort summary for the endpoint.
descriptionstringNoLonger description for the endpoint.
tagsstring[]NoAdditional tags for this route.
securitySecurityScheme[]NoSecurity schemes for this route.
hideFromDocsbooleanNoExclude this route from the spec.
statusCodenumberNoOverride the default success status code.

When using HTTP method decorators (@Get, @Post, etc.), the HTTP method and path are explicit. OpenAPI metadata goes directly in the decorator config:

import { Controller, Get, Post, RouterContext } from 'stratal/router'
import { z } from 'stratal/validation'
@Controller('/api/users', { tags: ['Users'] })
export class UsersController {
@Get('/', {
response: z.object({ data: z.array(z.object({ id: z.string(), name: z.string() })) }),
summary: 'List all users',
})
listUsers(ctx: RouterContext) {
return ctx.json({ data: [] })
}
@Post('/', {
body: z.object({ name: z.string(), email: z.string().email() }),
response: z.object({ id: z.string(), name: z.string(), email: z.string() }),
summary: 'Create a user',
statusCode: 201,
})
async createUser(ctx: RouterContext) {
const body = await ctx.body<{ name: string; email: string }>()
return ctx.json({ id: '1', ...body }, 201)
}
}

The statusCode property defaults to 200 for all HTTP method decorators. Set statusCode: 201 explicitly for resource creation endpoints.

The response property supports two forms. The shorthand passes a Zod schema directly:

@Route({
response: userSchema,
})

This generates a response with the description "Response 200" (or "Response 201" for create).

The object form lets you provide a custom description:

@Route({
response: {
schema: userSchema,
description: 'The created user',
},
})

Now that you understand how routes are derived, see Schemas and Validation to learn how Zod schemas work for both request validation and OpenAPI documentation.