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).

start/routes.ts
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 to Number.
  • 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.

start/routes.ts
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.

start/routes.ts
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.

core/controllers/post_controller.ts
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.
start/routes.ts
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.

start/routes.ts
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.

start/kernel.ts
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.

start/routes.ts
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