Middleware
A route’s chain is router middleware, then group middleware, then route middleware, then validation, then the handler — see How a request flows for where this sits relative to everything else. This page covers writing middleware, narrowing ctx, and mixing in the Express ecosystem.
ctx middleware and narrowing
Section titled “ctx middleware and narrowing”A Middleware<Adds> declares what it puts on ctx. Everything after it in the chain sees those fields as present, at router, group and route level.
import type { Middleware } from "@architectine/xil";
const authed: Middleware<{ user: User }> = async (ctx, next) => { ctx.user = await lookup(ctx.bearer()); // ctx.user is User | undefined inside await next();};
const router = new Router("/orders").use(authed);router.get("/:id").handle((ctx) => ctx.user.id); // User, not undefined
router.group("/admin", [requireAdmin], (r) => { /* r sees user and whatever requireAdmin adds */ });
router.post("/").use(rateLimited).handle((ctx) => ctx.user.id);Inline (ctx, next) arrows are typed with the state of the chain at that point, so ctx.params, ctx.body and earlier Adds are all available.
guard() covers one-line checks that either pass or throw:
router.use(guard((ctx) => ctx.user.role === "admin", () => new Forbidden()));Augmenting Ctx globally instead
Section titled “Augmenting Ctx globally instead”Middleware<Adds> narrows for what comes after it in one chain. For a field every request carries regardless of which middleware ran, merge into the Ctx interface instead — see Context: Augmenting Ctx. Prefer Middleware<Adds> whenever the field is actually conditional on a specific middleware running, since a global augmentation makes the field optional everywhere, even on routes that never run that middleware.
Express middleware
Section titled “Express middleware”Anything with the Express (req, res, next) or (err, req, res, next) shape is accepted unchanged, mixed freely with ctx middleware:
router.use(cors(), helmet(), authed);Declared Express middleware needs no annotations. An inline Express arrow must annotate its parameters ((req: Request, res: Response, next: NextFunction) => ...) or be typed as RequestHandler; this is the same TypeScript limitation Express has for inline error handlers.
Arity is the discriminator: two parameters means ctx middleware, three or four means Express.
toExpress(middleware) converts a ctx middleware into a plain Express RequestHandler, for handing to something that only accepts the Express shape.
The chain, mechanically
Section titled “The chain, mechanically”Middleware runs in registration order, outer to inner, and unwinds after await next(). The router sends the response after the chain has fully unwound, so middleware can still set headers after next():
router.use(async (ctx, next) => { const start = Date.now(); await next(); ctx.set("x-elapsed", String(Date.now() - start));});A ctx middleware must do one of four things: call next(), throw, respond through ctx.res, or return a response descriptor without calling next(). Doing none of them raises Internal("middleware ended without responding or calling next()").
Errors are thrown, never passed to next(err). The router catches them and forwards them to Express’s error path, where the xil error handler (or your own) renders them — see Errors.
Express middleware in the chain keeps Express semantics: next() continues, next(err) becomes a thrown error, next("route") skips to the next matching route, and ending the response without calling next() stops the chain. A four-argument (err, req, res, next) handler sees errors thrown by anything after it in the chain.
RouteSkip is what a ctx middleware returns internally to signal “the response was already sent, do not proceed” — you will not construct this yourself in ordinary use; isDescriptor() and the documented return conventions above cover what you write.
Middleware at app level
Section titled “Middleware at app level”app.use() on createApp accepts both shapes mixed freely, exactly like a router. The difference is scope: app-level middleware runs for every request, including ones no route matches, which is why cors(), helmet() and similar belong there rather than on a router. See createApp: Middleware at app level.