Routing
Register HTTP routes, group them with shared prefixes and middleware, validate URL parameters, and generate URLs by name.
Introduction
All routing in tekir goes through a single router instance provided by the framework. In start/routes.ts you export a default function that receives { router } from the TekirAppcontext, then call methods on it to describe your routes. After all routes are registered, tekir compiles them into Bun's native router for zero-overhead request dispatching.
Route handlers receive a single HttpContext argument that bundles everything about the request and a set of response helpers. You can return a plain object, a string, or a Response. tekir serialises it automatically.
Basic Routes
The router exposes one method per HTTP verb: get, post, put, delete, patch, any, and route (for multiple verbs at once).
import type { TekirApp } from '@tekir/core'
export default function({ router }: TekirApp) {
// GET /
router.get('/', ({ response }) => {
return response.ok({ message: 'Hello, world!' })
})
// POST /users
router.post('/users', async ({ body, response }) => {
const user = await createUser(body)
return response.created(user)
})
// PUT /users/:id
router.put('/users/:id', async ({ params, body, response }) => {
const user = await updateUser(params.id, body)
return response.ok(user)
})
// DELETE /users/:id
router.delete('/users/:id', async ({ params, response }) => {
await deleteUser(params.id)
return response.noContent()
})
// PATCH /users/:id
router.patch('/users/:id', async ({ params, body, response }) => {
return response.ok({ id: params.id, ...body })
})
// Match any HTTP method
router.any('/ping', () => ({ pong: true }))
// Match multiple specific methods
router.route('/form', ['GET', 'POST'], ({ request, response }) => {
if (request.method === 'POST') return response.ok({ submitted: true })
return response.html('<form method="POST"><button>Submit</button></form>')
})
}The handler return value is auto-serialised. You rarely need to call response helpers for simple cases:
// Return a plain object → tekir serialises it as JSON (200 OK)
router.get('/status', () => ({ ok: true }))
// Return a string → 200 text/plain
router.get('/text', () => 'Hello!')
// Return a Response directly → sent as-is
router.get('/raw', () => new Response('custom', { status: 200 }))Route Parameters
A segment prefixed with : is a route parameter. tekir captures it and puts the value into ctx.params as a string.
router.get('/posts/:slug', ({ params }) => {
// params.slug is always a string
return { slug: params.slug }
})
// Multiple parameters
router.get('/users/:userId/posts/:postId', ({ params }) => {
return { userId: params.userId, postId: params.postId }
})Parameter Validation
Use .where(param, matcher) on a RouteBuilder to validate and optionally cast a parameter before it reaches your handler. If the value does not match, the route is treated as not found (404). tekir ships three named built-in matchers:
router.matchers.number(): matches/^\d+$/and casts toNumber.router.matchers.uuid(): matches a v4 UUID.router.matchers.slug(): matches lowercase-with-dashes slugs.
// Built-in matchers
const { number, uuid, slug } = router.matchers
// Only match if :id is a numeric string: auto-cast to number
router.get('/users/:id', ({ params, response }) => {
// params.id is already a number thanks to cast: Number
return response.ok({ id: params.id })
}).where('id', number())
// Only match UUIDs
router.get('/tokens/:token', handler).where('token', uuid())
// Only match URL slugs (lowercase-with-dashes)
router.get('/posts/:slug', handler).where('slug', slug())
// Custom regex matcher
router.get('/orders/:ref', handler).where('ref', {
match: /^ORD-\d{6}$/
})You can also set a global matcher that applies to a named parameter across all routes:
// Anywhere :id appears, it will be validated as a number
router.where('id', router.matchers.number())Wildcard Segments
A * at the end of a path matches any remaining segments. The matched portion is available as params['*'].
// Catch-all: matches /files/foo, /files/foo/bar/baz, etc.
router.get('/files/*', ({ params }) => {
// The wildcard portion is in params['*']
return { path: params['*'] }
})Route Groups
Groups let you apply a shared prefix, middleware, or name prefix to a set of routes without repeating yourself. Call router.group(callback) and chain .prefix(), .use(), or .as() on the result. Any routes registered inside the callback automatically inherit those properties.
import { authMiddleware } from '~/middleware/auth'
// All routes defined inside the callback inherit the prefix
router.group(() => {
router.get('/users', listUsers)
router.post('/users', createUser)
router.get('/users/:id', showUser)
}).prefix('/api/v1')
// Groups can stack middleware on all their routes
router.group(() => {
router.get('/dashboard', showDashboard)
router.get('/settings', showSettings)
}).prefix('/admin').use(authMiddleware)
// Named groups: route names are prefixed with 'admin.'
router.group(() => {
router.get('/posts', listPosts).as('posts.index') // → 'admin.posts.index'
router.post('/posts', createPost).as('posts.store') // → 'admin.posts.store'
}).prefix('/admin').as('admin')
// Groups can be nested
router.group(() => {
router.group(() => {
router.get('/metrics', showMetrics)
}).prefix('/v2')
}).prefix('/api')Resource Routes
router.resource() generates all seven standard RESTful routes for a controller in a single call. This is the fastest way to expose a full CRUD API.
import { PostController } from '~/controllers/post_controller'
// Registers all 7 RESTful routes in one call:
//
// GET /posts → PostController.index
// GET /posts/create → PostController.create
// POST /posts → PostController.store
// GET /posts/:id → PostController.show
// GET /posts/:id/edit → PostController.edit
// PUT /posts/:id → PostController.update
// DELETE /posts/:id → PostController.destroy
//
router.resource('/posts', PostController)
// Only generate a subset of actions
router.resource('/posts', PostController).only(['index', 'show', 'store', 'destroy'])
// Exclude certain actions
router.resource('/posts', PostController).except(['create', 'edit'])
// API-only (skips create and edit: the HTML form pages)
router.resource('/posts', PostController).apiOnly()
// Apply middleware per action
router.resource('/posts', PostController).use({
store: [authMiddleware],
update: [authMiddleware],
destroy: [authMiddleware]
})The controller must have methods named index, create, store, show, edit, update, and destroy. tekir silently skips any that are missing.
export class PostController {
async index({ response }: HttpContext) {
return response.ok(await Post.all())
}
async create({ response }: HttpContext) {
return response.html('<form>...</form>')
}
async store({ body, response }: HttpContext) {
return response.created(await Post.create(body))
}
async show({ params, response }: HttpContext) {
return response.ok(await Post.findOrFail(params.id))
}
async edit({ params, response }: HttpContext) {
return response.html('<form>...</form>')
}
async update({ params, body, response }: HttpContext) {
return response.ok(await Post.update(params.id, body))
}
async destroy({ params, response }: HttpContext) {
await Post.destroy(params.id)
return response.noContent()
}
}Named Routes
Chain .as(name) on any route to give it a name. Named routes can be reversed into a URL using router.makeUrl(). Change the path in one place and every generated URL updates automatically.
// Assign a name to any route with .as()
router.get('/users', listUsers).as('users.index')
router.get('/users/:id', showUser).as('users.show')
router.post('/users', createUser).as('users.store')
// Build URLs from names anywhere in your app
const url = router.makeUrl('users.show', { id: '42' })
// → '/users/42'
// With query string parameters
const searchUrl = router.makeUrl('users.index', {}, { page: '2', q: 'alice' })
// → '/users?page=2&q=alice'Brisk Routes
Brisk routes are one-liner shortcuts that avoid writing a handler function for trivial responses. Call router.on(path) and chain the action you want.
.render(Component, props?): render a JSX view through the view engine..redirect(url, status?): redirect (default 302)..redirectToRoute(name, params?, options?): redirect to a named route..json(data): return a JSON object.
import { AboutPage } from '~/resources/views/about_page'
// Render a JSX view directly: no controller needed
router.on('/about').render(AboutPage, { title: 'About Us' })
// Redirect shorthand
router.on('/old-path').redirect('/new-path')
router.on('/moved').redirect('/destination', 301) // permanent
// Redirect to a named route
router.on('/dashboard').redirectToRoute('admin.dashboard')
// Return inline JSON
router.on('/health').json({ status: 'ok', version: '1.0.0' })Route Middleware
Chain .use(middleware) on any route builder to attach middleware that runs only for that route. Pass a single function or an array; they execute left to right, before the handler.
import { authMiddleware } from '~/middleware/auth'
import { rateLimitMiddleware } from '~/middleware/rate_limit'
// Single middleware
router.get('/profile', showProfile).use(authMiddleware)
// Multiple middleware: executed left to right
router.post('/posts', createPost).use([authMiddleware, rateLimitMiddleware])For middleware that should run on all routes, register it in start/kernel.ts with router.useRouter(). See Middleware for the full picture.
Controller Registration
If you prefer decorator-based controllers, use router.register() to register one or more controller classes. tekir inspects the decorators on each method and creates the corresponding routes automatically.
router.register(UserController, PostController, CommentController)Global & Router Middleware
tekir distinguishes two middleware stacks. router.useGlobal() registers server-level middleware that runs on every request, including 404s and static assets. router.useRouter() registers router-level middleware that only runs when a route actually matches.
router.useGlobal([cors(), helmet()])
// Router-level: runs only on MATCHED routes
router.useRouter([bodyParser(), serverTiming()])Lifecycle Hooks
Lifecycle hooks give you fine-grained control over the request pipeline. Register them on the router to intercept requests at different stages: before the handler runs, after the handler returns a response, and when an error is thrown.
router.onRequest((ctx) => { /* logging, auth check */ })
router.onBeforeHandle((ctx) => { /* validation, rate limiting */ })
// Run after route handler
router.onAfterHandle((ctx) => { /* transform response */ })
router.onAfterResponse((ctx) => { /* cleanup, metrics */ })
// Error handler
router.onError((error, ctx) => {
return ctx.response.internalServerError({ message: error.message })
})Multi-method Routes
Use router.route() to bind a single handler to multiple HTTP methods at once. This is useful for endpoints that share the same logic across GET and POST (for example, a form that renders on GET and processes on POST).
// Handle multiple HTTP methods with one handler
router.route('/api/resource', ['GET', 'POST'], handler)Listing Routes
Run the built-in routes CLI command to print a table of every registered route with its HTTP method, URL pattern, and name:
# Print every registered route with its method, pattern, and name
tekir routes
# Method Path Name
# ------------------------------------------------------------
# GET / home
# GET /api/users users.index
# POST /api/users users.store
# GET /api/users/:id users.show
# PUT /api/users/:id users.update
# DELETE /api/users/:id users.destroy