Skip to content

Middleware

Middleware runs before guards and route handlers. It can inspect or modify the request, short-circuit the response, or perform side effects like logging and timing. A module registers middleware by implementing RouteConfigurable and configuring the Router it receives.

A middleware class implements the Middleware interface with a single handle method:

import { Transient } from 'stratal/di'
import type { Middleware, Next, RouterContext } from 'stratal/router'
@Transient()
export class LoggingMiddleware implements Middleware {
async handle(ctx: RouterContext, next: Next): Promise<void> {
const start = Date.now()
console.log(`--> ${ctx.c.req.method} ${ctx.c.req.path}`)
await next()
const duration = Date.now() - start
console.log(`<-- ${ctx.c.req.method} ${ctx.c.req.path} ${duration}ms`)
}
}

Call next() to pass control to the next middleware in the chain (or the route handler if there are no more middlewares). Code before next() runs on the way in, and code after runs on the way out.

The Middleware, Next, and RouterContext types are all imported from stratal/router.

Modules register middleware by implementing the RouteConfigurable interface. This gives you a configureRoutes method that receives a Router:

import { Module } from 'stratal/module'
import type { RouteConfigurable } from 'stratal/router'
import { Router } from 'stratal/router'
import { LoggingMiddleware } from './logging.middleware'
import { UsersController } from './users.controller'
@Module({
controllers: [UsersController],
providers: [LoggingMiddleware],
})
export class UsersModule implements RouteConfigurable {
configureRoutes(router: Router): void {
router.middleware(LoggingMiddleware)
}
}

configureRoutes is called once during module initialization. It does not run on every request.

The Router is a fluent builder. Every configuration method returns the same router, so calls chain together.

middleware() registers one or more middleware classes scoped to the current module’s controllers. Pass them in the order you want them to run:

configureRoutes(router: Router): void {
router.middleware(CorsMiddleware, LoggingMiddleware)
}

In this example, CorsMiddleware runs first, then LoggingMiddleware, for every route declared by this module’s controllers.

use() registers global middleware that applies to every route in the entire application, not just the current module’s controllers:

configureRoutes(router: Router): void {
router.use(SecurityHeadersMiddleware, CorsMiddleware)
}

group() scopes middleware and other route configuration to a specific set of controllers. Pass the controllers and a callback that configures them:

configureRoutes(router: Router): void {
router.group([AdminController], (admin) => {
admin.middleware(AdminAuthMiddleware)
})
}

Controllers listed in a group() are removed from the module’s default scope, so middleware registered directly on router does not apply to them. The callback receives a router without use(), since global middleware belongs on the root.

Because every method returns the router, you can combine middleware with prefixes, domains, versions, and names inside a group:

configureRoutes(router: Router): void {
router.group([TenantController, BillingController], (r) => {
r.prefix('/tenant')
.domain('{tenant}.myapp.com')
.middleware(TenantMiddleware)
})
}

The scoped configuration methods available on the router and inside a group are:

MethodApplies to scope
middleware(...middlewares)Middleware classes for controllers in this scope
prefix(path, params?)A path prefix, with an optional Zod schema for its params
domain(pattern)A domain pattern for controllers in this scope
name(prefix)A name prefix for routes in this scope
version(version)An API version (string or string[]) for this scope
throttle(name)A named rate limiter for controllers in this scope
hideFromDocs(hide?)Hide or show this scope’s routes in the OpenAPI document

use() is the only method that is not scoped: it always registers global middleware on the whole application.

Targeting is expressed through scope rather than path strings:

  1. To run middleware on every route in the app, call router.use(...).

  2. To run middleware on every route in this module, call router.middleware(...) on the module’s router.

  3. To run middleware on specific controllers, list them in router.group([...], ...) and call middleware(...) inside the callback.

To target a versioned path, scope the group with .version(). For a prefixed path, scope it with .prefix(). See API Versioning for how versions map to URL segments.

A single module can mix global middleware, module-wide middleware, and several groups:

@Module({
controllers: [UsersController, AdminController, HealthController],
providers: [LoggingMiddleware, AuthMiddleware, AdminAuthMiddleware],
})
export class AppModule implements RouteConfigurable {
configureRoutes(router: Router): void {
// Global logging on every route in the app
router.use(LoggingMiddleware)
// Auth on this module's remaining controllers (Users, Health)
router.middleware(AuthMiddleware)
// Stricter auth scoped to the admin controller only
router.group([AdminController], (admin) => {
admin.middleware(AdminAuthMiddleware)
})
}
}

Because AdminController is listed in the group, the module-wide AuthMiddleware does not apply to it. The group’s AdminAuthMiddleware applies instead.

Middleware classes are resolved from the request-scoped DI container, so you can inject services:

import { Transient, inject } from 'stratal/di'
import type { Middleware, Next, RouterContext } from 'stratal/router'
import { LOGGER_TOKENS, type LoggerService } from 'stratal/logger'
@Transient()
export class RequestLoggerMiddleware implements Middleware {
constructor(
@inject(LOGGER_TOKENS.LoggerService) private readonly logger: LoggerService,
) {}
async handle(ctx: RouterContext, next: Next): Promise<void> {
this.logger.info('Request received', {
method: ctx.c.req.method,
path: ctx.c.req.path,
})
await next()
}
}

A middleware can respond directly by returning a Response instead of calling next(). This stops the chain, and the route handler never runs:

@Transient()
export class MaintenanceMiddleware implements Middleware {
constructor(
@inject(DI_TOKENS.CloudflareEnv) private readonly env: StratalEnv,
) {}
async handle(ctx: RouterContext, next: Next): Promise<Response | void> {
if (this.env.MAINTENANCE_MODE === 'true') {
return ctx.json({ message: 'Service is under maintenance' }, 503)
}
await next()
}
}

For request throttling, prefer the framework’s built-in named rate limiters over hand-rolled middleware. Scope a named limiter with throttle():

configureRoutes(router: Router): void {
router.group([UploadsController], (uploads) => {
uploads.prefix('/uploads').throttle('uploads')
})
}

See Rate Limiting for registering named limiters.

The complete request lifecycle shows where middleware fits:

Request
-> Request-scoped container setup
-> Global middleware (router.use)
-> Scoped middleware (router.middleware / group middleware)
-> Guards (controller-level, then method-level)
-> Schema validation (@Route body, params, query)
-> Route handler
-> Response

Within middleware, execution order is determined by:

  1. Global before scoped: middleware from use() runs before scoped middleware.
  2. Registration order: within a single configureRoutes() call, middleware registered first runs first.
  3. Chain order: within a single middleware() or use() call, middleware listed first runs first.

Every middleware has access to the request-scoped DI container through RouterContext:

@Transient()
export class CorrelationMiddleware implements Middleware {
async handle(ctx: RouterContext, next: Next): Promise<void> {
const requestId = ctx.header('X-Request-ID') ?? crypto.randomUUID()
// Register the request ID in the request container
const container = ctx.getContainer()
container.registerValue(REQUEST_ID_TOKEN, requestId)
await next()
}
}

Services resolved later in the request (guards, controllers, other middleware) can then inject REQUEST_ID_TOKEN to access the correlation ID.

  • Guards to learn about access control that runs after middleware.
  • Domain Routing for routing requests based on subdomain patterns.
  • Signed URLs for generating tamper-proof, expiring links.
  • Modules for how modules organize middleware and other providers.
  • Dependency Injection for more on the request-scoped container.