Compose shared logic, authentication guards, and cascading typed state with layout files. Understand middleware execution ordering and state propagation.
Layout files in Taser.js allow you to organize cross-cutting concerns (authentication, CORS, rate limiting, logging) and inject typed state into child routes without repeating code in every handler.
A layout file is any TypeScript file inside src/routes/ that does not end with an HTTP method. It exports a default middleware pipeline initialized with t.layout(layoutId) or layout(layoutId).
src/routes/admin.ts
import { forbidden, unauthorized } from "@taserjs/router/reply";import { t } from "@taserjs/router";export default t.layout("/admin").use(async (ctx, next) => { const authHeader = ctx.headers.get("authorization"); if (!authHeader?.startsWith("Bearer ")) { return unauthorized({ message: "Admin authorization required" }); } const token = authHeader.slice(7); const adminUser = await verifyAdminToken(token); if (!adminUser) { return forbidden({ message: "Insufficient permissions" }); } // Injects adminUser directly into ctx.state for all downstream routes return next({ adminUser });});
All route files in src/routes/admin/ (or src/routes/admin.*.ts) automatically inherit this middleware and receive ctx.state.adminUser fully typed.
The root layout applies to every route in your application. It is the ideal place for global concerns like CORS, security headers, request timing, and tenant resolution:
src/routes/$.ts
import { cors } from "@taserjs/router/cors";import { secureHeaders } from "@taserjs/router/secure-headers";import { timing } from "@taserjs/router/timing";import { t } from "@taserjs/router";export default t .layout("/*") .use( cors({ origin: ["https://app.example.com", "https://admin.example.com"], credentials: true, }), ) .use(secureHeaders()) .use(timing());
While src/routes/$.ts applies globally to every route in your application, src/routes/index.ts is scoped specifically to root index endpoints (such as src/routes/index.get.ts). It mounts with t.layout('/index'):
src/routes/index.ts
import { t } from "@taserjs/router";export default t.layout("/index").use(async (_ctx, next) => { // Only runs for the root '/' endpoint return next();});
Nested directory index files (e.g. src/routes/admin/index.ts) similarly mount with t.layout('/admin/index') and execute only for GET /admin (src/routes/admin/index.get.ts).
When middleware calls next({ session }), the properties merge directly into ctx.state. Downstream handlers access these properties with complete TypeScript inference:
src/routes/dashboard.ts
import { unauthorized } from "@taserjs/router/reply";import { t } from "@taserjs/router";export default t.layout("/dashboard").use(async (ctx, next) => { const session = await getSession(ctx.headers.get("cookie")); if (!session) { return unauthorized({ message: "Invalid session" }); } return next({ session });});
Middlewares created via middleware() or t.middleware() automatically inherit your application's boot and request context (ctx.db, ctx.requestId, etc.) via ambient type registration with zero manual type annotations:
src/middleware/tenant-logger.ts
import { middleware } from "@taserjs/router";export const tenantLogger = middleware(async (ctx, next) => { // ctx.db and ctx.requestId are 100% typed from your taser.context definition! ctx.db.logTenantAccess(ctx.requestId); return next();});
TypeScript guarantees that requireActiveUser can only be attached to routes or layouts under the "/users" branch:
src/routes/users/settings.get.ts
import { json } from "@taserjs/router/reply";import { t } from "@taserjs/router";import { requireActiveUser } from "../../middleware/user-guard";// Allowed: Route inherits "/users" layoutexport default t .get("/users/settings") .use(requireActiveUser) .handler((ctx) => { return json({ tier: ctx.state.userTier }); });
If you attempt to mount it on an unrelated branch, TypeScript emits an immediate compile error:
src/routes/posts/$id.get.ts
import { ok } from "@taserjs/router/reply";import { t } from "@taserjs/router";// ❌ TypeScript Error: Cannot attach middleware scoped to "/users" layout on "/posts" branchexport default t .get("/posts/:id") .use(requireActiveUser) .handler((ctx) => ok());
Middlewares can also enforce path parameters (validated against the route URL string e.g. /users/:userId) or query schemas provided by upstream layouts:
src/middleware/load-user.ts
export const loadUser = middleware() .requires<{ params: { userId: string } }>() .handler(async (ctx, next) => { const user = await db.users.findById(ctx.params.userId); return next({ user }); });// Allowed: Route has ":userId" in its patht.get("/users/:userId/profile").use(loadUser)...// ❌ TypeScript Error: Route "/profile" has no :userId path parametert.get("/profile").use(loadUser)...
When .use(...) is added to a route or layout, TypeScript inspects the preceding middleware chain and route path. If the required facets are not satisfied, TypeScript produces a compile error:
Route definitions in Taser.js follow a strict phased lifecycle:
Middleware Phase (.use(...)) — Chained at the beginning of the route.
Contract / Schema Phase (.query(), .params(), .body(), .returns()) — Once schemas or return maps are declared, .use() is locked out to maintain deterministic execution order.
Execution Phase (.handler(...)) — The terminal route handler.
export default t .get("/users/:id") // 1. Middlewares at the top: .use(cors()) .use(requireAdmin) // 2. Contracts and schemas: .query(z.object({ details: z.boolean().default(false) })) .returns({ 200: UserResponseSchema }) // 3. Handler: .handler((ctx) => { return json({ id: ctx.params.id, isAdmin: ctx.state.isAdmin }); });