Skip to content

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.

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 shape
examples/minimal/src/index.ts
router.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;
});
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, VALIDATION
examples/minimal/src/index.ts
router
.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
});
examples/minimal/src/index.ts
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.

@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.

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.

examples/auth-drizzle/src/adapter.ts
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:

examples/auth-drizzle/src/adapter.ts
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,
};
},
examples/auth-drizzle/src/index.ts
const auth = createAuth<User>({
adapter,
strategies: { session: cookieSession({ ttl: "30d" }) },
default: "session",
});
const router = new Router("/auth");

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.”

examples/auth-drizzle/src/index.ts
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.

examples/auth-drizzle/src/index.ts
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;
});

auth.require("session") narrows ctx.user to User, not User | undefined — the type reflects that this route can’t be reached without one.

examples/auth-drizzle/src/index.ts
router
.get("/me")
.use(auth.require("session"))
.handle((ctx) => ctx.user);
Terminal window
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/logout

The SQLite file and its tables are created automatically on first run. Full source: examples/auth-drizzle.

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).