Controllers
Organise your route handlers into controller files using plain functions, classes, or decorator-based classes.
Introduction
Controllers are where your request-handling logic lives. tekir gives you three ways to write controllers, pick whichever fits your style:
- Functional controllers: export plain functions from a file, wire them in
start/routes.tswithrouter.get()etc. - Resource controllers: export a class with RESTful method names, register with
router.resource()to auto-generate all CRUD routes. - Decorator controllers: use
@Controller,@Get,@Postfrom@tekir/http-decoratorsfor a more declarative approach.
All three receive the same HttpContext object and produce the same result. The difference is purely organisational.
Functional Controllers
The simplest approach: export named functions from a file, then import and wire them to routes manually. No classes, no decorators, no magic.
Basic Example
import type { HttpContext } from '@tekir/core'
import { db } from '#services'
export async function index({ response }: HttpContext) {
const users = await db.query('SELECT * FROM users')
return response.ok(users)
}
export async function show({ params, response }: HttpContext) {
const user = await db.queryOne('SELECT * FROM users WHERE id = ?', [params.id])
if (!user) return response.notFound({ message: 'User not found' })
return response.ok(user)
}
export async function store({ body, response }: HttpContext) {
const user = await db.run('INSERT INTO users (name, email) VALUES (?, ?)', [body.name, body.email])
return response.created(user)
}
export async function update({ params, body, response }: HttpContext) {
await db.run('UPDATE users SET name = ? WHERE id = ?', [body.name, params.id])
return response.ok({ id: params.id, ...body })
}
export async function destroy({ params, response }: HttpContext) {
await db.run('DELETE FROM users WHERE id = ?', [params.id])
return response.noContent()
}Then wire them up in your routes file:
import type { TekirApp } from '@tekir/core'
import * as UserController from '~/controllers/user_controller'
import * as PostController from '~/controllers/post_controller'
export default function ({ router }: TekirApp) {
// Each route points to a specific function
router.get('/api/users', UserController.index)
router.get('/api/users/:id', UserController.show)
router.post('/api/users', UserController.store)
router.put('/api/users/:id', UserController.update)
router.delete('/api/users/:id', UserController.destroy)
router.get('/api/posts', PostController.index)
router.get('/api/posts/:id', PostController.show)
router.post('/api/posts', PostController.store)
}Grouping Routes
Use router.group() to share a prefix and middleware across related routes:
import type { TekirApp } from '@tekir/core'
import * as UserController from '~/controllers/user_controller'
import * as PostController from '~/controllers/post_controller'
import { authenticate, silentAuth, guest } from '@tekir/auth'
export default function ({ router }: TekirApp) {
// Group under /api/users with shared prefix
router.group(() => {
router.get('/', UserController.index)
router.get('/:id', UserController.show)
router.post('/', UserController.store)
router.put('/:id', UserController.update)
router.delete('/:id', UserController.destroy)
}).prefix('/api/users')
// Groups can have middleware + name prefix
router.group(() => {
router.get('/', PostController.index)
router.get('/:id', PostController.show)
router.post('/', PostController.store).use(authenticate)
}).prefix('/api/posts').as('posts')
}Route Middleware
Chain .use() on any route to add per-route middleware, or apply it at the group level to cover all routes in the group:
import type { TekirApp } from '@tekir/core'
import * as UserController from '~/controllers/user_controller'
import { authenticate, silentAuth, guest } from '@tekir/auth'
import { validate } from '@tekir/validator'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(2),
email: z.string().email()
})
export default function ({ router }: TekirApp) {
// Per-route middleware with .use()
router.get('/api/users', UserController.index)
router.post('/api/users', UserController.store).use([authenticate, validate({ body: createUserSchema })])
// Group-level middleware applies to all routes in the group
router.group(() => {
router.put('/api/users/:id', UserController.update)
router.delete('/api/users/:id', UserController.destroy)
}).use(authenticate)
}Resource Controllers
A resource controller is a class with standard RESTful method names: index, show, store, update, destroy, create, and edit. Register it with router.resource() and tekir generates all 7 routes automatically.
import type { HttpContext } from '@tekir/core'
import { db } from '#services'
export default class PostController {
async index({ response }: HttpContext) {
return response.ok(await db.query('SELECT * FROM posts'))
}
async show({ params, response }: HttpContext) {
const post = await db.queryOne('SELECT * FROM posts WHERE id = ?', [params.id])
if (!post) return response.notFound({ message: 'Post not found' })
return response.ok(post)
}
async store({ body, response }: HttpContext) {
const post = await db.run('INSERT INTO posts (title, body) VALUES (?, ?)', [body.title, body.body])
return response.created(post)
}
async update({ params, body, response }: HttpContext) {
await db.run('UPDATE posts SET title = ? WHERE id = ?', [body.title, params.id])
return response.ok({ id: params.id, ...body })
}
async destroy({ params, response }: HttpContext) {
await db.run('DELETE FROM posts WHERE id = ?', [params.id])
return response.noContent()
}
}import type { TekirApp } from '@tekir/core'
import PostController from '~/controllers/post_controller'
export default function ({ router }: TekirApp) {
// Generates all 7 RESTful routes automatically:
// GET /posts → index
// GET /posts/create → create
// POST /posts → store
// GET /posts/:id → show
// GET /posts/:id/edit → edit
// PUT /posts/:id → update
// DELETE /posts/:id → destroy
router.resource('posts', PostController)
}Resource Options
You can limit which routes are generated and add per-action middleware:
// Only include specific actions
router.resource('posts', PostController).only(['index', 'show', 'store'])
// Exclude specific actions
router.resource('posts', PostController).except(['create', 'edit'])
// API-only (excludes create + edit: the form pages)
router.resource('posts', PostController).apiOnly()
// Per-action middleware
router.resource('posts', PostController).use({
store: [authenticate],
update: [authenticate],
destroy: [authenticate]
})Decorator Controllers
If you prefer a more declarative style, install @tekir/http-decorators and use decorators to describe your routes. Decorators are an optional package, not part of the core.
@Controller
The @Controller(prefix) decorator marks a class as a controller and sets the URL prefix for all its route methods.
import { Controller, Get, Post, Put, Delete } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'
import { db } from '#services'
@Controller('/api/users')
export class UserController {
@Get('/')
async index({ response }: HttpContext) {
return response.ok(await db.query('SELECT * FROM users'))
}
@Get('/:id')
async show({ params, response }: HttpContext) {
const user = await db.queryOne('SELECT * FROM users WHERE id = ?', [params.id])
if (!user) return response.notFound({ message: 'User not found' })
return response.ok(user)
}
@Post('/')
async store({ body, response }: HttpContext) {
await db.run('INSERT INTO users (name, email) VALUES (?, ?)', [body.name, body.email])
const user = await db.queryOne('SELECT * FROM users ORDER BY id DESC LIMIT 1')
return response.created(user)
}
@Put('/:id')
async update({ params, body, response }: HttpContext) {
await db.run('UPDATE users SET name = ? WHERE id = ?', [body.name, params.id])
return response.ok(await db.queryOne('SELECT * FROM users WHERE id = ?', [params.id]))
}
@Delete('/:id')
async destroy({ params, response }: HttpContext) {
await db.run('DELETE FROM users WHERE id = ?', [params.id])
return response.noContent()
}
}The prefix can include or omit the leading slash; tekir normalises it either way. Pass an empty string or call @Controller() with no arguments to register routes at their exact paths. Pass an array for multiple prefixes.
// Single prefix, every method is under /api/users
@Controller('/api/users')
export class UserController { ... }
// Prefix without leading slash: tekir adds it automatically
@Controller('api/users')
export class UserController { ... }
// Empty prefix: routes registered at exactly their @Get/@Post paths
@Controller()
export class HealthController {
@Get('/health')
check() { return { status: 'ok' } }
}
// Multiple prefixes: same controller under multiple paths
@Controller(['/api/v1/users', '/api/v2/users'])
export class UserController {
@Get('/')
index({ response }: HttpContext) { return response.ok([]) }
}Method Decorators
Each HTTP verb has a matching decorator. The first argument is the path relative to the controller prefix. The second argument is an optional options object supporting name and where.
import { Controller, Get, Post, Put, Delete, Patch, Head, Options } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'
@Controller('/api/articles')
export class ArticleController {
@Get('/')
index({ response }: HttpContext) { return response.ok([]) }
@Get('/:id')
show({ params, response }: HttpContext) { return response.ok({ id: params.id }) }
@Post('/')
store({ body, response }: HttpContext) { return response.created(body) }
@Put('/:id')
update({ params, body, response }: HttpContext) { return response.ok({ id: params.id, ...body }) }
@Patch('/:id')
patch({ params, body, response }: HttpContext) { return response.ok({ id: params.id, ...body }) }
@Delete('/:id')
destroy({ response }: HttpContext) { return response.noContent() }
@Head('/')
head({ response }: HttpContext) { return response.noContent() }
@Options('/')
options({ response }: HttpContext) {
return response.header('Allow', 'GET, POST, PUT, PATCH, DELETE').noContent()
}
}@Middleware
Apply middleware to individual methods with the @Middleware decorator. It accepts an array of middleware functions, applied in order before the handler.
import { Controller, Get, Post, Middleware } from '@tekir/http-decorators'
import { validate } from '@tekir/validator'
import type { HttpContext } from '@tekir/core'
import { authenticate, silentAuth, guest } from '@tekir/auth'
import { z } from 'zod'
const createSchema = z.object({
title: z.string().min(3),
body: z.string().min(10)
})
@Controller('/api/posts')
export class PostController {
@Get('/')
index({ response }: HttpContext) { return response.ok([]) }
@Get('/mine')
@Middleware([authenticate])
myPosts({ response }: HttpContext) { return response.ok([]) }
@Post('/')
@Middleware([authenticate, validate({ body: createSchema })])
async store({ body, response }: HttpContext) {
return response.created(body)
}
}@Cache
Cache full HTTP responses on a controller method by adding the @Cache decorator. It is a thin wrapper around the cache() middleware from @tekir/cache, so every option there (ttl, vary, key, skip) is available here too. CacheProvider must be registered for the default store to be picked up automatically.
import { Controller, Get, Cache } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'
@Controller('/api/posts')
export class PostController {
// Default 60-second TTL, store inferred from CacheProvider
@Get('/')
@Cache({ ttl: 60 })
async list() {
return Post.all()
}
// Per-post entries via a custom key
@Get('/:id')
@Cache({ ttl: 300, key: (ctx) => `post:${ctx.params.id}` })
async show({ params }: HttpContext) {
return Post.find(params.id)
}
// Skip caching for authenticated admins
@Get('/feed')
@Cache({ ttl: 30, skip: (ctx) => ctx.auth?.user?.role === 'admin' })
async feed() {
return Post.published()
}
}Parameter Validation
Pass a where object in the route decorator options to validate and optionally cast URL parameters. If the parameter does not satisfy the regex, tekir returns a 404.
import { Controller, Get, Delete } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'
@Controller('/api/users')
export class UserController {
// :id must be a number: auto-cast before handler runs
@Get('/:id', {
where: {
id: { match: /^\d+$/, cast: Number }
}
})
async show({ params, response }: HttpContext) {
// params.id is now a number
return response.ok({ id: params.id })
}
// UUID constraint: validation only, no cast
@Delete('/:token', {
where: {
token: {
match: /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
}
}
})
async revokeToken({ params, response }: HttpContext) {
return response.noContent()
}
}Registering Decorator Controllers
Decorator controllers must be passed to router.register():
import type { TekirApp } from '@tekir/core'
import { UserController } from '~/controllers/user_controller'
import { PostController } from '~/controllers/post_controller'
export default function({ router }: TekirApp) {
router.register(UserController, PostController)
}HttpContext
Every handler (functional, resource, or decorator) receives the same HttpContext object. Import the type from @tekir/core.
import type { HttpContext } from '@tekir/core'
// The full HttpContext shape:
interface HttpContext {
request: TekirRequest // rich request object (see Request docs)
response: TekirResponse // rich response builder (see Response docs)
params: Record<string, string> // URL route parameters
query: Record<string, string | string[]> // parsed query string
headers: Record<string, string> // request headers as plain object
cookies: Record<string, string> // parsed cookies
body: any // parsed request body
route: { pattern: string; name?: string } // matched route info
redirect: (url: string, status?: number) => Response // quick redirect helper
status: (code: number, body?: any) => Response // quick status helper
store: Record<string, any> // per-request key-value store
[key: string]: any // middleware can attach anything
}Generating Controllers
The tekir CLI can scaffold a controller with all CRUD methods pre-filled:
# Generate a controller with CRUD methods
tekir make:controller Post
# Created: core/controllers/post_controller.tsimport type { HttpContext } from '@tekir/core'
export async function index({ response }: HttpContext) {
return response.ok([])
}
export async function show({ params, response }: HttpContext) {
return response.ok({ id: params.id })
}
export async function store({ body, response }: HttpContext) {
return response.created(body)
}
export async function update({ params, body, response }: HttpContext) {
return response.ok({ id: params.id, ...body })
}
export async function destroy({ response }: HttpContext) {
return response.noContent()
}Add --decorator to generate a decorator-based controller instead:
# Generate a decorator-based controller
tekir make:controller Post --decorator
# Created: core/controllers/post_controller.tsimport { Controller, Get, Post, Put, Delete } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'
@Controller('/post')
export class PostController {
@Get('/')
index({ response }: HttpContext) {
return response.ok([])
}
@Get('/:id')
show({ params, response }: HttpContext) {
return response.ok({ id: params.id })
}
@Post('/')
store({ body, response }: HttpContext) {
return response.created(body)
}
@Put('/:id')
update({ params, body, response }: HttpContext) {
return response.ok({ id: params.id, ...body })
}
@Delete('/:id')
destroy({ response }: HttpContext) {
return response.noContent()
}
}