Examples
Two runnable apps live in the repository’s examples/ directory, type-checked in CI alongside the framework itself. Run either with pnpm --filter <name> start from the repo root; each README has the exact commands. Every snippet below is quoted directly from that source, not retyped, so it can’t drift out of sync with it.
minimal
Section titled “minimal”The smallest useful xil app: one router, a validated body, a thrown error rendered in the unified shape. Start here if you haven’t read Your first app yet — this is the same code, in full.
A route with no schema, and a thrown error
Section titled “A route with no schema, and a thrown error”curl http://localhost:3000/orders/1 # -> 200 { "id": "1", "total": 42 }curl http://localhost:3000/orders/999 # -> 404, unified error shaperouter.get("/:id").handle((ctx) => { const order = orders.get(ctx.params.id); if (!order) throw new NotFound(`Order ${ctx.params.id} does not exist`, { id: ctx.params.id }); return order;});A validated body
Section titled “A validated body”curl -X POST http://localhost:3000/orders -H 'content-type: application/json' -d '{"total": 10}'# -> 201 { "id": "2", "total": 10 }
curl -X POST http://localhost:3000/orders -H 'content-type: application/json' -d '{"total": -5}'# -> 422, VALIDATIONrouter .post("/") .body(z.object({ total: z.number().positive() })) .handle((ctx) => { const order: Order = { id: String(orders.size + 1), total: ctx.body.total }; orders.set(order.id, order); return order; // POST -> 201, per the response conventions });Wiring it up
Section titled “Wiring it up”const app = createApp({ logger: { level: "info" } });app.mount(router);
const port = Number(process.env.PORT ?? 3000);await app.listen(port, () => { console.log(`minimal example listening on http://localhost:${port}`); console.log(` GET /orders/1`); console.log(` POST /orders { "total": 10 }`);});Full source: examples/minimal/src/index.ts.
auth-drizzle
Section titled “auth-drizzle”@architectine/xil/auth wired to a real database: an AuthAdapter implemented on Drizzle ORM against SQLite, backing signup, login, GET /me, and logout. Read Auth alongside this for what each piece means.
The adapter
Section titled “The adapter”AuthAdapter<User> is a plain interface — no base class, no decorators. This implementation is Drizzle over SQLite; a Postgres or MySQL adapter looks the same shape, with real awaits.
export interface User { readonly id: string; readonly email: string; readonly name: string;}Session methods store one hashed token per row, shared by cookie sessions and any bearer/refresh tokens a real app adds later:
async createSession({ tokenHash, userId, familyId, expiresAt }) { db.insert(sessions).values({ tokenHash, userId, familyId, expiresAt, rotatedAt: null }).run();},
async findSession(tokenHash) { const row = db.select().from(sessions).where(eq(sessions.tokenHash, tokenHash)).get(); if (!row) return null; return { userId: row.userId, familyId: row.familyId, expiresAt: row.expiresAt, rotatedAt: row.rotatedAt, createdAt: row.createdAt, };},Configuring auth
Section titled “Configuring auth”const auth = createAuth<User>({ adapter, strategies: { session: cookieSession({ ttl: "30d" }) }, default: "session",});
const router = new Router("/auth");Signup
Section titled “Signup”Hashes the password, creates the user, then calls auth.login() to set the session cookie — the same call a login route makes, since both end in “this user now has a session.”
router .post("/signup") .body( z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().min(1) }), ) .handle(async (ctx) => { const existing = await adapter.findUserByEmail(ctx.body.email); if (existing) return ctx.status(409).json({ code: "EMAIL_TAKEN", message: "Email already registered" });
const passwordHash = await auth.hashPassword(ctx.body.password); const user: User = { id: randomUUID(), email: ctx.body.email, name: ctx.body.name }; db.insert(users) .values({ ...user, passwordHash }) .run();
await auth.login(ctx, user); return ctx.status(201).json(user); });auth.verifyPassword() throws Unauthorized on any mismatch — including an unknown email, which it treats identically in both response and timing. See Auth: passwords.
router .post("/login") .body(z.object({ email: z.string().email(), password: z.string() })) .handle(async (ctx) => { const user = await auth.verifyPassword(ctx.body.email, ctx.body.password); // throws Unauthorized on mismatch await auth.login(ctx, user); return user; });A protected route
Section titled “A protected route”auth.require("session") narrows ctx.user to User, not User | undefined — the type reflects that this route can’t be reached without one.
router .get("/me") .use(auth.require("session")) .handle((ctx) => ctx.user);Running it
Section titled “Running it”pnpm --filter auth-drizzle start
curl -c cookies.txt -X POST http://localhost:3000/auth/signup \ -H 'content-type: application/json' \ -d '{"email":"ada@example.com","password":"correct horse","name":"Ada"}'
curl -b cookies.txt http://localhost:3000/auth/me
curl -b cookies.txt -X POST http://localhost:3000/auth/logoutThe SQLite file and its tables are created automatically on first run. Full source: examples/auth-drizzle.
What’s not covered by an example yet
Section titled “What’s not covered by an example yet”Neither example touches uploads, rate limiting, OpenAPI, config, events, or cache directly — each of those modules’ guide pages has its own complete, runnable-shaped snippet instead. If you’re wiring several of these together in one app, the recipes page has the combinations most likely to trip you up (auth plus rate limiting on a login route, in particular).