Skip to content

Router

The router is chainable and typed. You declare a path, optional schemas, then hand it a handler. The router owns the send step: whatever the handler returns becomes the response.

import { Router } from "@architectine/xil";
import { z } from "zod";
const orders = new Router("/orders");
orders
.get("/:id")
.params(z.object({ id: z.coerce.number() }))
.query(z.object({ expand: z.array(z.string()).optional() }))
.summary("Fetch one order")
.handle(async (ctx) => {
ctx.params.id; // number
ctx.query.expand; // string[] | undefined
return findOrder(ctx.params.id);
});

This page covers the type layer: declaring params, schemas, statics and redirects, and what the router records about each route. For middleware and narrowing, see Middleware. For what a handler’s return value turns into, see Responses.

Parameters are inferred from the path string, including the router prefix and any group prefixes above the route.

Path ctx.params
"/orders/:id/lines/:lineId" { id: string; lineId: string }
"/files/*path" { path: string }
"/files/:name.:ext" { name: string; ext: string }
"/users{/:id}" { id?: string }
"/orders" {}

Path syntax follows Express 5 (path-to-regexp v8): :name and *name parameters, {...} optional groups. A path typed as plain string falls back to Record<string, string>.

new Router("/orgs/:orgId").group("/projects", (r) => {
r.get("/:projectId").handle((ctx) => {
ctx.params; // { orgId: string; projectId: string }
});
});

A params schema must declare exactly the parameters in the path. Anything else is a compile error whose message names the offending keys. The schema may transform values; path values always arrive as strings.

orders.get("/:id").params(z.object({ id: z.coerce.number() })); // ctx.params: { id: number }
orders.get("/:id").params(z.object({ orderId: z.string() })); // error: missing "id", extra "orderId"

.query(), .body(), .headers() and .response() accept any Standard Schema value. Zod, Valibot and ArkType all work; xil has no dependency on any of them.

router.post("/a").body(z.object({ name: z.string() }));
  • .body() is not offered on GET, DELETE, HEAD or OPTIONS routes.
  • Without a schema, ctx.query is Record<string, string | string[]>, ctx.body is unknown, and ctx.headers.all() is Record<string, string | string[] | undefined>.
  • .response(schema) constrains what the handler may return: a value of the schema’s output type, or a response descriptor from ctx.json() and friends. .response(status, schema) is documentation only — it does not validate at runtime, but is picked up by the OpenAPI module.

These are picked up by the OpenAPI module and are otherwise inert at runtime — safe to add incrementally.

router
.get("/:id")
.summary("Fetch one order")
.tags("orders")
.response(OrderSchema)
.errors(NotFound)
.deprecated() // marks the operation deprecated in the generated spec
.hidden() // excludes the route from the generated spec entirely
.handle(...);

.errors(...classes) lists the HttpError subclasses a route can throw, so the OpenAPI module documents their responses alongside .response()’s success shape.

Validation runs after router and group middleware and before route middleware, in this order: params, query, headers, uploads, body. Only the first failing section is reported, as Unprocessable (422):

{
"code": "VALIDATION",
"message": "Invalid body",
"details": {
"in": "body",
"issues": [{ "path": "items.1.qty", "code": "too_small", "message": "..." }]
},
"requestId": "..."
}
  • Path params and query values arrive as strings; use coercion in the schema (z.coerce.number()).
  • A single query value is promoted to a one-element array when the schema wants an array, so ?tags=a and ?tags=a&tags=b both validate against z.array(z.string()).
  • Unknown keys follow the schema’s own policy. Zod and Valibot strip them by default; use .strict() to reject.
  • Header names are lower-case. After validation ctx.headers.all() returns the validated object; ctx.headers.get() always reads the raw request.
  • The body is whatever the body parser put on req.body. createApp always installs a JSON parser; urlencoded parsing is off by default (body.urlencoded in createApp). On bare Express, install express.json() yourself — see Using xil in an existing Express app.
router.static("/assets", "./public", { maxAge: "1d", immutable: true });
router.static("/app", "./dist", { spa: true }); // serves index.html for unmatched GETs that accept HTML
router.redirect("/old", "/new"); // 302
router.redirect("/gone", "/elsewhere", 301);

Both inherit the router’s prefix and middleware. maxAge accepts a duration string. Everything else is passed to express.static.

router.routes() returns every route beneath the router, including groups and mounts, with full paths, parameter names, schemas, the middleware chain (outer to inner) and the documentation fields (summary, tags, responses, errors, deprecated, hidden). router.statics() and router.redirects() list the rest. Routes are recorded when .handle() is called.

Router-level use() applies to routes registered after it, as in Express. Groups copy the parent’s middleware at creation; mounts copy it at mount time.

app.routes() returns the same RouteInfo[] for every router mounted on an app, with mount prefixes applied — that is what the OpenAPI module reads.

A Router instance can be used directly on a bare express() app, or compiled to a plain express.Router with .express():

const app = express();
app.use(express.json());
app.use(orders); // full paths, including the router prefix
app.use("/v2", orders.express()); // or mount the compiled express.Router yourself

Each route compiles to one Express handler that runs the whole chain: router and group middleware, validation, route middleware, then the handler. Routes registered after the first request are picked up automatically. Because middleware compiles per route, router-level middleware runs only for requests that match one of the router’s routes, statics or redirects — put cors(), helmet() and other “every request” middleware on the app instead, where Express runs it for unmatched paths and preflights too.