Skip to content

Middleware

Routa middleware is part of the contract, not a side effect. Each middleware declares the context keys it requires, the context it provides, and the early responses it can return. Auth, tenant loading, and request enrichment stay visible in types, in generated metadata, and in OpenAPI.

src/middleware/auth.ts
import { createMiddleware } from "@routa-ts/core";
import { z } from "zod";
export const withAuth = createMiddleware({
requires: ["session"],
provides: {
auth: z.object({
userId: z.string(),
}),
},
rejects: {
unauthorized: {
status: 401,
schema: z.object({ message: z.string() }),
},
},
run: ({ ctx, next }) => {
if (!ctx.session.authenticated || !ctx.session.userId) {
return { type: "unauthorized", data: { message: "Authentication required." } };
}
return next({ auth: { userId: ctx.session.userId } });
},
});

requires lists context keys that must already exist. Routa checks the order statically, so a middleware that requires session before anything provides it fails routa check with ROUTA_MIDDLEWARE_ORDER rather than at request time.

provides maps new context keys to schemas. Calling next({ auth }) parses every declared value before Routa merges any of them into ctx, so handlers receive transformed schema outputs and never a partial update. Undeclared top-level keys are omitted. A normal z.object() strips unknown keys inside its value; add .strict() when those nested keys must be rejected. This validation runs in both routa dev and routa start, independent of the response-validation policy.

rejects declares early responses. Returning { type: "unauthorized", data } ends the request with the declared status and skips the rest of the chain, including the handler. Those outcomes are merged into the route’s response map, so they are type-checked in handlers and included in generated OpenAPI. Their response data follows the same runtime response-validation policy as handler responses.

input is optional and works exactly like a route’s input. It lets middleware read validated headers, cookies, or query values without the route declaring them:

src/middleware/context.ts
export const withRequest = createMiddleware({
input: {
headers: z.object({
"x-request-id": z.string().optional(),
}),
},
provides: {
requestId: z.string(),
},
run: async ({ input, next }) => {
return next({ requestId: input.headers["x-request-id"] ?? "local-request" });
},
});

A middleware.ts file applies to every route in its folder and below. Export or re-export middleware contracts from it; the export order is the execution order.

src/routes/middleware.ts
export { withRequest, withSession } from "../middleware/context.js";

Context accumulates as routing gets more specific, which is why a group folder is a useful place to attach a chain:

  • Directorysrc/routes/
    • middleware.ts withRequest, withSession
    • Directory(private)/
      • middleware.ts withAuth
      • Directoryadmin/
        • middleware.ts withAdmin
        • Directoryaudit-events/
          • route.ts sees requestId, session, auth, admin
      • Directorytenants/
        • Directory$tenantId/
          • middleware.ts withTenant
          • Directoryprojects/
            • route.ts sees requestId, session, auth, tenant

A route file can add middleware for all of its methods, or one method can add its own:

src/routes/reports/route.ts
export default route({
middleware: [withRateLimit],
get: createRoute({
middleware: [withReportAccess],
// ...
}),
post: createRoute({
// runs withRateLimit, but not withReportAccess
// ...
}),
});

The full chain for a method is folder middleware, then route-level middleware, then method-level middleware.

Middleware can describe what an operation requires without Routa implementing any of it:

createMiddleware({
openapi: {
security: [{ bearerAuth: [] }],
permissions: ["audit.read"],
},
// Application-owned credential and permission logic in run.
});

Routa emits security and x-routa-authz on every operation the middleware covers, and routa openapi breaking reports when a previously public operation becomes authenticated.

  • Every reject name must be unique across a method’s whole chain. Two middleware both declaring forbidden on the same route is a registration error.
  • Reject statuses must be 4xx or 5xx (ROUTA_MIDDLEWARE_REJECT_STATUS).
  • Always await or return next(). Calling it without awaiting drops the downstream result and can leave the request hanging.
  • next() may be called at most once per middleware invocation.
  • Middleware must be referenced as a direct identifier of a createMiddleware export. Spreading an array or computing the value at runtime produces ROUTA_MIDDLEWARE_UNRESOLVED, because Routa could not check the contract.

Routa makes security context and early responses visible at the HTTP boundary. Your application still owns authorization rules, policy correctness, credential handling, and secrets. Routa never authenticates a request for you.