Middleware

Intercept requests and responses with composable middleware functions.

Introduction

Middleware are async functions that sit between the incoming HTTP request and your route handler. They can inspect or modify the request, run side effects (logging, authentication, rate limiting), modify the response, or abort the chain entirely by returning a Response without calling next().

Every middleware has exactly this signature:

import type { HttpContext } from '@tekir/core'

// Every middleware has this exact signature:
type MiddlewareFunction = (
  ctx: HttpContext,
  next: () => Promise<void>
) => void | Response | Promise<void | Response>

Before and After Pattern

Middleware form an onion: each layer wraps the next. Code before await next() runs on the way in; code after await next() runs on the way out, after the handler has already produced a result.

Request arrives
  │
  ▼
[global middleware 1]   ← useGlobal (CORS, session)
  │  await next()
  ▼
[global middleware 2]
  │  await next()
  ▼
[router middleware 1]   ← useRouter (logging, request-id)
  │  await next()
  ▼
[route middleware 1]    ← .use() on the route / @Middleware on the method
  │  await next()
  ▼
[handler]               ← your controller method or inline function
  │  returns result
  ▼
[route middleware 1]    ← code after await next()
  │
  ▼
[router middleware 1]
  │
  ▼
[global middleware 2]
  │
  ▼
[global middleware 1]
  │
Response sent

tekir has three stacking levels, executed in this order:

  • Server-level (router.useGlobal()): runs on every request, even if no route matches.
  • Router-level (router.useRouter()): runs only on requests that match a registered route.
  • Route-level (.use() or @Middleware): runs only for a specific route or controller method.

Creating a Middleware

Generate a middleware with the CLI:

tekir make:middleware RequestLogger

A middleware is a plain async function. Export it as the default and import it where needed.

Here is a request logger that measures how long each request takes:

core/middleware/request_logger.ts
import { logger } from '#services'
import type { HttpContext } from '@tekir/core'

// "before" code runs before await next()
// "after"  code runs after  await next()
export default async function requestLogger(ctx: HttpContext, next: () => Promise<void>) {
  const start = performance.now()

  await next() // run the next middleware + handler

  const ms = (performance.now() - start).toFixed(2)
  logger.info(`${ctx.request.method} ${ctx.route.pattern} ${ms}ms`)
}

And here is an authentication guard that reads a Bearer token and attaches the decoded user to ctx.store so handlers downstream can access it:

core/middleware/auth_guard.ts
import type { HttpContext } from '@tekir/core'

export default async function authGuard(ctx: HttpContext, next: () => Promise<void>) {
  const token = ctx.headers['authorization']?.replace('Bearer ', '')
  if (!token) return ctx.response.unauthorized({ message: 'Missing token' })

  // Decode / verify token...
  const user = await verifyJwt(token)
  if (!user) return ctx.response.unauthorized({ message: 'Invalid token' })

  // Attach user to context so downstream handlers can read it
  ctx.store.user = user

  await next()
}

The kernel.ts File

start/kernel.ts is the central place to wire up providers, global middleware, and named middleware. It runs once at application boot, before the first request arrives.

start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { cors } from '@tekir/cors'
import { session } from '@tekir/session'
import { AuthProvider } from '@tekir/auth'
import { DatabaseProvider } from '@tekir/db'
import { CacheProvider } from '@tekir/cache'
import { ViewProvider } from '@tekir/view'
import requestLogger from '~/middleware/request_logger'
import addRequestId from '~/middleware/add_request_id'

export default function({ router, app, config }: TekirApp) {
  /*
   * Service providers, boot packages like DB, cache, auth
   */
  app.registerAll([
    DatabaseProvider,
    CacheProvider,
    AuthProvider,
    ViewProvider
  ])

  /*
   * Server-level middleware
   * Runs on EVERY request, even ones that don't match any route.
   * Use for CORS, sessions, and anything truly global.
   */
  router.useGlobal([
    cors(config('cors')),
    session(config('session'))
  ])

  /*
   * Router-level middleware
   * Runs only when a route is matched.
   * Use for logging, request IDs, and app-wide guards.
   */
  router.useRouter([
    addRequestId,
    requestLogger
  ])
}

Server-level Middleware

Register with router.useGlobal(middleware). Accepts a single function or an array. Use this for cross-cutting concerns like CORS headers and session cookies that must be present even on 404 responses.

Router-level Middleware

Register with router.useRouter(middleware). These run only after a route has been matched, so they have access to ctx.route.pattern and route parameters. Logging, request IDs, and application-wide auth guards belong here.

After Middleware (ctx.$result)

After await next()returns, the handler's return value is stored in ctx.$result. Middleware can read and replace this value before tekir serialises it into a Response. This is the correct way to add headers to every outgoing response, wrap responses in an envelope, or transform the result.

core/middleware/add_request_id.ts
import type { HttpContext } from '@tekir/core'

export default async function addRequestId(ctx: HttpContext, next: () => Promise<void>) {
  // BEFORE: generate a unique ID and store it
  const requestId = crypto.randomUUID()
  ctx.store.requestId = requestId

  await next() // the handler runs, result is stored in ctx.$result

  // AFTER: attach the ID to the outgoing response
  const result = ctx.$result

  if (result instanceof Response) {
    result.headers.set('X-Request-Id', requestId)
  } else if (result && typeof result === 'object') {
    // If the handler returned a plain object, wrap it in a Response
    ctx.$result = Response.json(result, {
      headers: { 'X-Request-Id': requestId, 'Content-Type': 'application/json' }
    })
  }
}

Attaching Middleware to Routes

Route-level middleware runs after global and router middleware. You can attach it three ways:

import authGuard from '~/middleware/auth_guard'

// On a standalone route
router.get('/secret', handler).use(authGuard)

// In a group: all routes in the group get the middleware
router.group(() => {
  router.get('/dashboard', showDashboard)
  router.post('/settings', updateSettings)
}).prefix('/admin').use(authGuard)

// Multiple middleware: executed left to right
router.post('/posts', createPost).use([authGuard, rateLimiter])

Short-circuiting the Chain

Return a Response (or call a response helper and return its result) from middleware without calling next() to abort the chain. No subsequent middleware or the handler will run.

core/middleware/maintenance.ts
import type { HttpContext } from '@tekir/core'
import env from '#env'

export default async function maintenanceMode(ctx: HttpContext, next: () => Promise<void>) {
  if (env.MAINTENANCE) {
    // Return early WITHOUT calling next(): the chain stops here
    return ctx.response.serviceUnavailable({ message: 'We are down for maintenance.' })
  }

  // Normal operation: continue chain
  await next()
}