# Authorization ## The `authorize` function [#the-authorize-function] Every `ZenRouter` requires an `authorize` function. It runs before every route handler. See the [Request lifecycle](/docs/request-lifecycle). Return a falsy value to reject the request with `403 Forbidden`. Return any truthy value to allow the request, and that value becomes available as `auth` in the handler. ```ts const zen = new ZenRouter({ authorize: async ({ req }) => { const token = req.headers.get("Authorization"); const user = await db.getUserByToken(token); if (!user) return false; // → 403 Forbidden return { currentUser: user }; }, }); zen.route("GET /api/posts", async ({ auth }) => { // auth.currentUser is fully typed return db.getPostsByUser(auth.currentUser.id); }); ``` Auth is **mandatory by design**. This ensures you can never accidentally expose a route without auth. ## Public routes [#public-routes] If none of your routes require authentication, explicitly opt out by returning `true`. ```ts const zen = new ZenRouter({ authorize: () => true, }); // ⚠️ Now all these routes are public! zen.route("GET /api/health", ...); zen.route("GET /api/version", ...); zen.route("POST /api/webhooks/stripe", ...); ``` If most routes require auth but a few don’t, split the public ones into a separate, isolated `ZenRouter` instance and then [compose them together](/docs/composing-routers). ## The `getContext` function [#the-getcontext-function] `getContext` is an optional hook for attaching metadata to a request. The return value becomes available as `ctx` in every handler and in `authorize`. ```ts const zen = new ZenRouter({ getContext: (req) => ({ db: getDbConnection(), logger: createLogger(req), }), authorize: async ({ req, ctx }) => { ctx.logger.info("Checking auth..."); const token = req.headers.get("Authorization"); const currentUser = await ctx.db.getUserByToken(token); if (!currentUser) return false; return { currentUser }; }, }); zen.route( "GET /api/posts/", async ({ ctx, auth, p }) => { ctx.logger.info(`Fetching post ${p.postId}`); return ctx.db.getPostById(p.postId); } ); ``` Use context for static metadata like database connections or loggers. Do not use it for auth: that’s what the mandatory `authorize` is for. See the [Request lifecycle](/docs/request-lifecycle) for a full overview of how a request flows through the router. # Composing routers A typical backend serves endpoints to different audiences, each with its own requirements: your main API might need token auth, admin routes require stricter access, webhook receivers verify request signatures, and some routes need no auth at all. Different audiences may also need different error responses — user-facing APIs benefit from friendly, helpful error messages, while internal endpoints can be more terse. Zen Router lets you group routes with the same requirements into separate `ZenRouter` instances, then bind them together with `ZenRelay`. If you only need a single auth strategy, a single `ZenRouter` is enough. No `ZenRelay` needed. ## ZenRelay [#zenrelay] `ZenRelay` is nothing but a thin dispatch layer. All it does is look at an incoming request, select which router handles it based on the given URL prefix, and pass it to the first matching router. It does no parsing, no validation, no auth — that’s all handled by the `ZenRouter` instance it dispatches to. ```ts title="src/index.ts" import { ZenRelay } from "@liveblocks/zenrouter"; import { zen as authRoutes } from "./routes/auth"; import { zen as apiRoutes } from "./routes/api"; import { zen as adminRoutes } from "./routes/admin"; import { zen as webhookRoutes } from "./routes/webhooks"; const app = new ZenRelay(); app.relay("/auth/*", authRoutes); app.relay("/api/admin/*", adminRoutes); app.relay("/api/*", apiRoutes); app.relay("/webhooks/*", webhookRoutes); export default app; ``` Routes inside each router are still **fully qualified**. The relay prefix is only used for dispatch. It does not strip or rewrite the URL. Each router receives the original, unmodified request: ```ts title="src/routes/api.ts" const zen = new ZenRouter({ authorize }); zen.route("GET /api/posts", handler); zen.route("GET /api/posts/", handler); // ^^^^^^^^^^^^^^^^^^^^^^^^ // Always the full path, never relative to the relay prefix export { zen }; ``` ## No base prefixes [#no-base-prefixes] There is no way to set a "base" prefix on a `ZenRouter` or `ZenRelay` that gets prepended to your route definitions. Every route pattern is always the **complete, greppable URL path**. This is [intentional](/docs/principles#maintainable). If you want to find the handler for `/api/posts`, you grep for `/api/posts`. No indirection, no prefix math. ## No fall-through [#no-fall-through] There is **no implicit fall-through** between routers. This is a [core principle](/docs/principles#maintainable). For example, if a request is made to `/api/admin/users/123`, only the `/api/admin/*` router will ever see it. If that router doesn’t have a matching route handler, it will return 404. The `/api/*` router will deliberately **not** get a chance to handle it. Each router has its own auth and error handling behavior, so routers are fully isolated from each other. ## Keep it flat [#keep-it-flat] The encouraged pattern is a single `ZenRelay` layer at the top level, dispatching to one set of `ZenRouter` instances. Don’t nest relays or routers. It only makes the routing harder to follow. # CORS When you enable CORS on a router, Zen Router does two things: 1. **Handles `OPTIONS` preflight requests automatically.** Browsers send these before making certain cross-origin requests. Zen Router responds with `204` and the correct `Access-Control-*` headers. 2. **Adds CORS headers to all responses.** So the browser allows your JavaScript to read the response. ## Do I need CORS? [#do-i-need-cors] You only need to enable CORS if your routes are going to be called from a browser with JavaScript running on a different origin (e.g. your frontend at `app.example.com` calls your API at `api.example.com`). Without CORS, the browser will block those requests. You do not need CORS if your routes are only called from other backend servers, or if they’re on the same origin as your frontend (e.g. your frontend and API are both at `example.com`). ## Enabling CORS [#enabling-cors] Pass `cors: true` to the router to enable CORS with sensible defaults across all routes. {/* prettier-ignore */} ```ts const zen = new ZenRouter({ authorize: () => { /* ... */ }, // [!code highlight] cors: true, }); ``` CORS is configured per-router, not per-route. With Zen Router, routes with similar requirements and behavior are [grouped in the same router](/docs/composing-routers) already, so this is almost always what you want. For example, typically only your public API router needs CORS since it’s the only one browsers talk to directly. ## Custom configuration [#custom-configuration] For more control, pass a configuration object: {/* prettier-ignore */} ```ts const zen = new ZenRouter({ authorize: () => { /* ... */ }, // [!code highlight] cors: { // [!code highlight] allowedOrigins: ["https://example.com", "https://app.example.com"], // [!code highlight] allowCredentials: true, // [!code highlight] exposeHeaders: ["X-Request-Id"], // [!code highlight] maxAge: 86400, // [!code highlight] }, }); ``` | Option | Default | Description | | ------------------ | ------- | ---------------------------------------------------------------------------------------- | | `allowedOrigins` | `"*"` | Which origins are allowed. `"*"` allows any origin. | | `allowedMethods` | All | Which methods to allow, e.g. `["GET", "POST"]` | | `allowedHeaders` | `"*"` | Which headers browsers may include in **requests**, e.g. `["X-Acme-Id"]`. | | `allowCredentials` | `false` | Whether to allow cookies and TLS client certificates. | | `exposeHeaders` | `[]` | Which **response** headers browser scripts can access, e.g. `["X-RateLimit-Remaining"]`. | | `maxAge` | — | How long (in seconds) browsers may cache preflight responses. | | `sendWildcard` | `false` | Send `*` instead of echoing back the Origin. Cannot be used with `allowCredentials`. | | `alwaysSend` | `true` | Send CORS headers even if the request has no `Origin` header. | | `varyHeader` | `true` | Add `Vary: Origin` header to responses. | # Error handling ## Aborting from handlers [#aborting-from-handlers] Use `abort()` to short-circuit a handler and return an error response: ```ts import { abort } from "@liveblocks/zenrouter"; zen.route( "GET /api/posts/", async ({ p }) => { const post = await db.getPostById(p.postId); if (!post) { // [!code highlight] abort(404); } return { id: post.id, title: post.title }; } ); ``` `abort()` throws internally and never returns. By default, it produces a JSON response like `{ "error": "Not Found" }` using the standard message for the status code. You can customize the error shape with the router’s error handler (see below). ## Default error shape [#default-error-shape] By default, all error responses are JSON with at least an `error` key: ```json { "error": "Not Found" } ``` For validation errors (422), a `reason` field is included with details: ```json { "error": "Unprocessable Entity", "reason": "Value at key 'title': expected string" } ``` ## Custom error handling [#custom-error-handling] Use `onError` to customize the error response shape for all HTTP errors. For example, when `abort(404)` is called, `.onError()` will get called with an error object containing `{ status: 404, message: "Not Found" }`: ```ts zen.onError((error, { req, ctx }) => { // error.status — the HTTP status code // error.message — the error message return json( { code: error.status, message: error.message, path: new URL(req.url).pathname, }, error.status ); }); ``` Use `onUncaughtError` to handle unexpected errors (bugs, unhandled exceptions): ```ts zen.onUncaughtError((error, { req, ctx }) => { console.error("Uncaught error:", error); return json({ error: "Something went wrong" }, 500); }); ``` Both handlers can only be registered once per router. ## Shared error handler [#shared-error-handler] If you use `ZenRelay` with multiple routers, you can share an `ErrorHandler` instance across them: ```ts import { ErrorHandler, ZenRouter, ZenRelay } from "@liveblocks/zenrouter"; const errorHandler = new ErrorHandler(); errorHandler.onError((error) => { return json({ code: error.status, message: error.message }, error.status); }); const api = new ZenRouter({ authorize: myAuth, errorHandler }); const admin = new ZenRouter({ authorize: adminAuth, errorHandler }); const app = new ZenRelay({ errorHandler }); ``` ## Built-in error responses [#built-in-error-responses] In addition to any `abort()` calls you make manually, Zen Router automatically aborts under these conditions: | Status | When | | ----------------------------- | -------------------------------------------- | | **400** Bad Request | Path params are malformed or fail validation | | **403** Forbidden | `authorize` returns a falsy value | | **404** Not Found | No route matches the URL | | **405** Method Not Allowed | URL matches but HTTP method doesn’t | | **422** Unprocessable Entity | Request body fails schema validation | | **500** Internal Server Error | Uncaught error in a handler | See also the [Request lifecycle](/docs/request-lifecycle). # Zen Router Zen Router is an opinionated API router built by [Liveblocks](https://liveblocks.io), where it has been powering billions of requests per month. It’s designed for Cloudflare Workers, Bun, Node.js, and other modern JavaScript runtimes. npm pnpm bun yarn ```bash npm install @liveblocks/zenrouter ``` ```bash pnpm add @liveblocks/zenrouter ``` ```bash bun add @liveblocks/zenrouter ``` ```bash yarn add @liveblocks/zenrouter ``` ## Quick example [#quick-example] Full Auth Route params zod decoders CORS ```ts import { ZenRouter } from "@liveblocks/zenrouter"; import { z } from "zod"; const zen = new ZenRouter({ authorize: async ({ req }) => { const token = req.headers.get("Authorization"); const currentUser = await db.getUserByToken(token); if (!currentUser) return false; return { currentUser }; }, }); // Get a specific post zen.route( "GET /api/posts/", async ({ p, auth }) => { const post = await db.getPost(auth.currentUser.id, p.postId); return { id: post.id, title: post.title }; } ); // Create a new post for the current user zen.route( "POST /api/posts", z.object({ title: z.string() }), async ({ auth, body }) => { const post = await db.createPost({ title: body.title, authorId: auth.currentUser.id, }); return { id: post.id, title: post.title }; } ); export default zen; ``` ```ts import { ZenRouter } from "@liveblocks/zenrouter"; import { z } from "zod"; const zen = new ZenRouter({ // [!code highlight] authorize: async ({ req }) => { const token = req.headers.get("Authorization"); // [!code highlight] const currentUser = await db.getUserByToken(token); // [!code highlight] if (!currentUser) return false; // [!code highlight] return { currentUser }; // [!code highlight] }, // [!code highlight] }); // Get a specific post zen.route( "GET /api/posts/", // [!code highlight] async ({ p, auth }) => { const post = await db.getPost(auth.currentUser.id, p.postId); // [!code highlight] return { id: post.id, title: post.title }; } ); // Create a new post for the current user zen.route( "POST /api/posts", z.object({ title: z.string() }), // [!code highlight] async ({ auth, body }) => { const post = await db.createPost({ title: body.title, // [!code highlight] authorId: auth.currentUser.id, }); return { id: post.id, title: post.title }; } ); export default zen; ``` ```ts import { ZenRouter } from "@liveblocks/zenrouter"; import { z } from "zod"; const zen = new ZenRouter({ authorize: async ({ req }) => { const token = req.headers.get("Authorization"); const currentUser = await db.getUserByToken(token); if (!currentUser) return false; return { currentUser }; }, }); // Get a specific post zen.route( // [!code highlight] "GET /api/posts/", // [!code highlight] async ({ p, auth }) => { const post = await db.getPost(auth.currentUser.id, p.postId); // [!code highlight] return { id: post.id, title: post.title }; } ); // Create a new post for the current user zen.route( "POST /api/posts", z.object({ title: z.string() }), async ({ auth, body }) => { const post = await db.createPost({ title: body.title, authorId: auth.currentUser.id, }); return { id: post.id, title: post.title }; } ); export default zen; ``` ```ts import { ZenRouter } from "@liveblocks/zenrouter"; // [!code highlight] import { z } from "zod"; const zen = new ZenRouter({ authorize: async ({ req }) => { const token = req.headers.get("Authorization"); const currentUser = await db.getUserByToken(token); if (!currentUser) return false; return { currentUser }; }, }); // Get a specific post zen.route( "GET /api/posts/", async ({ p, auth }) => { const post = await db.getPost(auth.currentUser.id, p.postId); return { id: post.id, title: post.title }; } ); // Create a new post for the current user zen.route( "POST /api/posts", // [!code highlight] z.object({ title: z.string() }), async ({ auth, body }) => { const post = await db.createPost({ title: body.title, authorId: auth.currentUser.id, }); return { id: post.id, title: post.title }; } ); export default zen; ``` ```ts import { ZenRouter } from "@liveblocks/zenrouter"; // [!code highlight] import { object, string } from "decoders"; const zen = new ZenRouter({ authorize: async ({ req }) => { const token = req.headers.get("Authorization"); const currentUser = await db.getUserByToken(token); if (!currentUser) return false; return { currentUser }; }, }); // Get a specific post zen.route( "GET /api/posts/", async ({ p, auth }) => { const post = await db.getPost(auth.currentUser.id, p.postId); return { id: post.id, title: post.title }; } ); // Create a new post for the current user zen.route( "POST /api/posts", // [!code highlight] object({ title: string }), async ({ auth, body }) => { const post = await db.createPost({ title: body.title, authorId: auth.currentUser.id, }); return { id: post.id, title: post.title }; } ); export default zen; ``` ```ts import { ZenRouter } from "@liveblocks/zenrouter"; import { z } from "zod"; const zen = new ZenRouter({ authorize: async ({ req }) => { const token = req.headers.get("Authorization"); const currentUser = await db.getUserByToken(token); if (!currentUser) return false; return { currentUser }; }, // [!code highlight] cors: true, }); // Get a specific post zen.route( "GET /api/posts/", async ({ p, auth }) => { const post = await db.getPost(auth.currentUser.id, p.postId); return { id: post.id, title: post.title }; } ); // Create a new post for the current user zen.route( "POST /api/posts", z.object({ title: z.string() }), async ({ auth, body }) => { const post = await db.createPost({ title: body.title, authorId: auth.currentUser.id, }); return { id: post.id, title: post.title }; } ); export default zen; ``` ## Features [#features] * [Type-safe everywhere](/docs/routes). Your handler receives `{ p, q, body }`, all fully typed. * [Body validation](/docs/routes#body-validation). With any [Standard Schema](https://standardschema.dev/) compatible library. * [Authorization](/docs/authorization). Mandatory by design, opt-out for public routes. * [Composing routers](/docs/composing-routers). Isolate routers by auth strategy with `ZenRelay`. * [CORS](/docs/cors). Built-in, not bolted on. * [Error handling](/docs/error-handling). `abort(404)` from any handler, customizable error shapes. * [Response helpers](/docs/response-helpers). `json()`, `html()`, `textStream()`, and more. * [OpenTelemetry](/docs/opentelemetry). Automatic span attributes for matched routes. * [Runs anywhere](/docs/supported-runtimes). Cloudflare Workers, Bun, Node.js, and any runtime with Web `Request`/`Response`. # OpenTelemetry Zen Router can automatically set span attributes on the active OpenTelemetry span when a route matches. This gives you per-route observability with a single config option. Zen Router has no dependency on `@opentelemetry/api` itself. You provide the `getActiveSpan` function, so the coupling is entirely in your code. ## Setup [#setup] Pass an `otel` config with a `getActiveSpan` function: ```ts // [!code highlight] import { trace } from "@opentelemetry/api"; const zen = new ZenRouter({ authorize, // [!code highlight] otel: { // [!code highlight] getActiveSpan: () => trace.getActiveSpan(), // [!code highlight] }, }); ``` ## Span attributes [#span-attributes] When a route matches, Zen Router sets these attributes on the active span: | Attribute | Example | Description | | ------------------ | ----------------------------- | ------------------------- | | `zen.route` | `GET /api/posts/` | The matched route pattern | | `zen.param.` | `zen.param.postId = "abc123"` | Each decoded path param | These attributes are set early in the request lifecycle, so they are recorded even if the request later fails due to auth or validation errors. # Principles Zen Router is an opinionated router. These are the principles that underlie its API design. ## Pragmatic [#pragmatic] * Implementing real-world endpoints should be **joyful**, **easy**, and **type-safe**. * All requests and responses are **JSON by default**. For convenience, you can directly return a JSON value and it will be turned into a proper JSON `Response` with the correct headers. * You can *throw* any HTTP error to short-circuit a non-2xx response. * JSON error responses for all common HTTP status codes, customizable per status code. * All error responses have at least an `{ error }` key with a human-readable string. * **CORS support is built-in** with a sane `{ cors: true }` default that applies to all endpoints in the router. `OPTIONS` routes and responses are managed automatically. ## Secure by default [#secure-by-default] * All requests must be authorized. **Authorization is opt-out, not opt-in.** * All **path params are verified and type-safe** (`/foo//` available as `p.bar` and `p.qux`), cannot be empty, and are URI-decoded automatically. * Incoming JSON request bodies **must be validated**, and are made available as a fully type-safe `body` in the handler. * All query strings are type-safely accessible (`/foo?abc=hi` as `q.abc`). ## Maintainable [#maintainable] * All route patterns are **fully qualified, and thus [greppable](https://morizbuesing.com/blog/greppability-code-metric/)**. No "base" prefix URL setup, which in practice makes codebases harder to navigate over time. * Routes always **include the method in their definition**, so `"POST /api/posts"` instead of `.post("/api/posts")`. There is no such thing as `.all("/api/posts")`. * **No middlewares.** Middlewares are easy to bolt on but get harder to trace in large codebases over time: which ones are active, what do they do, what did they attach to the request object? Zen Router replaces all of that with a single request context (`ctx`) that carries data alongside a request. Figuring out where contextual data comes from is one "Jump to definition" away. * **No fall-through.** Once a route handler runs, it *must* return a response. It never silently falls through to another handler. The same applies to [composed routers](/docs/composing-routers). * Default error handling is **configurable per status code**; individual handlers can always bypass it by throwing a custom Response. * If you return a JSON value from your handler directly, it **must be an object**, not an array. We do this to encourage building [extensible](https://maltzj.com/posts/avoiding-json-arrays) responses by default. # Request lifecycle The Zen Router request lifecycle # Response helpers Handlers can return a plain object and Zen Router will serialize it as JSON with status `200` automatically. For other response types, use these helpers. ## json [#json]

`json(value, status?, headers?)`

Returns a JSON response with the correct content type. ```ts import { json } from "@liveblocks/zenrouter"; zen.route( "POST /api/posts", schema, async ({ auth, body }) => { const post = await db.createPost(body); return json({ id: post.id }, 201); } ); ``` ## html [#html]

`html(content, status?, headers?)`

Returns an HTML response. ```ts import { html } from "@liveblocks/zenrouter"; zen.route( "GET /health", () => { return html("

OK

"); } ); ``` ## abort [#abort]

`abort(status, headers?)`

Throws an error response. See [error handling](/docs/error-handling) for details. ```ts import { abort } from "@liveblocks/zenrouter"; abort(404); // never returns ``` ## textStream [#textstream]

`textStream(iterable, headers?, options?)`

Returns a streaming text response from a string generator. Chunks are buffered to reduce per-chunk overhead (default buffer size: 64 KB). ```ts import { textStream } from "@liveblocks/zenrouter"; zen.route( "GET /api/stream", () => { function* generate() { yield "Hello "; yield "world!"; } return textStream(generate()); } ); ``` ## ndjsonStream [#ndjsonstream]

`ndjsonStream(iterable, headers?)`

Returns a streaming [NDJSON](https://github.com/ndjson/ndjson-spec) (Newline Delimited JSON) response. Each value is serialized as a single line. ```ts import { ndjsonStream } from "@liveblocks/zenrouter"; zen.route( "GET /api/events", () => { function* events() { yield { type: "start" }; yield { type: "data", value: 42 }; yield { type: "end" }; } return ndjsonStream(events()); } ); ``` # Defining routes Routes are defined with a single string containing the HTTP method and the full path: Route params Type-safe Auto URI-decoding ```ts zen.route( "GET /hello/", ({ p }) => `Hello, ${p.name}!` ); ``` ```ts zen.route( "GET /hello/", ({ p }) => `Hello, ${p.nam}!` // ~~~ Property 'nam' does not exist on type '{ name: string }' ); ``` ```ts zen.route( "GET /hello/", // e.g. /hello/Jos%C3%A9%20Fran%C3%A7ois ({ p }) => `Hello, ${p.name}!` // Hello, José François! ); ``` Route params are enclosed in angle brackets. They are automatically URI-decoded and cannot be empty or optional. Multiple params work as expected: ```ts zen.route( "GET /api/rooms//users/", async ({ p }) => { // p.roomId and p.userId are both typed strings } ); ``` All route paths are **fully qualified and [greppable](https://morizbuesing.com/blog/greppability-code-metric/)**. There are no base prefixes, not on `ZenRouter`, not on `ZenRelay`. Every route definition is always the complete URL path. This is one of the [core principles](/docs/principles#maintainable). If a request is made to a URL that doesn’t match any route, Zen Router returns `404 Not Found`. If a request is made to a *path* that exists but with a method that isn’t defined, Zen Router will automatically return the correct `405 Method Not Allowed` response. ## Route param schema [#route-param-schema] By default, all path params will get exposed as strings in `p`. This is a good default since path params are strings. If you want, however, you can limit their allowed inputs or convert them to other types (e.g. numbers, enums, etc.). You can define route param schema at the router level using any [Standard Schema](https://standardschema.dev/) compatible library: zod decoders ```ts import { z } from "zod"; const zen = new ZenRouter({ authorize, params: { index: z.coerce.number(), color: z.enum(["red", "green", "blue"]), }, }); zen.route( "GET /api/posts//authors/", // ^^^^^^ ^^^^^ // string number ({ p }) => ... ); zen.route( "POST /api/posts//tag/", // ^^^^^^ ^^^^^ // string 'red' | 'green' | 'blue' ({ p }) => ... ); ``` ```ts import { numeric, oneOf } from "decoders"; const zen = new ZenRouter({ authorize, params: { index: numeric, color: oneOf(["red", "green", "blue"]), }, }); zen.route( "GET /api/posts//authors/", // ^^^^^^ ^^^^^ // string number ({ p }) => ... ); zen.route( "POST /api/posts//tag/", // ^^^^^^ ^^^^^ // string 'red' | 'green' | 'blue' ({ p }) => ... ); ``` If any param fails this validation, a `400 Bad Request` will be returned. See the [Request lifecycle](/docs/request-lifecycle). For example, a request to `/api/posts/123/tag/yellow` would be rejected because `yellow` is not a valid color. ## Body validation [#body-validation] To validate a request body, pass a schema as the second argument to `.route()`. The body is validated before your handler runs, so `body` is already parsed and fully typed. If you don’t provide a schema, `body` is typed as `never` so TypeScript won’t let you access it. Accessing it at runtime throws an error. zod decoders No body Bypass ```ts import { z } from "zod"; zen.route( "POST /api/posts", // [!code highlight] z.object({ title: z.string() }), async ({ auth, body }) => { const post = await db.createPost({ // [!code highlight] title: body.title, authorId: auth.currentUser.id, }); return { id: post.id, title: post.title }; } ); ``` ```ts import { object, string } from "decoders"; zen.route( "POST /api/posts", // [!code highlight] object({ title: string }), async ({ auth, body }) => { const post = await db.createPost({ // [!code highlight] title: body.title, authorId: auth.currentUser.id, }); return { id: post.id, title: post.title }; } ); ``` ```ts zen.route( "GET /api/posts", // [!code highlight] /* no validator here as 2nd param */ async ({ body }) => { // [!code highlight] // body is typed as 'never' } ); ``` ```ts zen.route( "POST /api/posts", // [!code highlight] z.unknown(), // Don’t validate but keep body accessible async ({ body }) => { // [!code highlight] // body is typed as 'unknown' } ); ``` Zen Router supports any validation library that implements the [Standard Schema](https://standardschema.dev/) spec. For use on Cloudflare Workers, we recommend [decoders](https://decoders.cc/) for its smaller bundle size and better error messages. If the body doesn’t match the schema, Zen Router returns `422 Unprocessable Entity` with a human-readable error message. See the [Request lifecycle](/docs/request-lifecycle). Body validation is not tied to any specific HTTP method. It’s based on arity: if you pass 3 arguments to `.route()`, the second is treated as a body schema. This is typically used with `POST`, `PUT`, or `PATCH`, but works with any method. ## Aliasing routes [#aliasing-routes] Sometimes the same endpoint needs to live under more than one path (a legacy URL kept alive next to its replacement, a versioned prefix, etc.). Rather than duplicating the handler — and re-deriving the same `{ ctx, auth, p, body }` types — register an **alias** to an existing route with `.alias()`: ```ts // Canonical version zen.route( "POST /api/posts", z.object({ text: z.string() }), async ({ p, body }) => { ... } ); // Singular alias zen.alias("POST /api/post", "POST /api/posts"); ``` An alias reuses the target route’s handler, body schema, and authorization. It is registered as a full route in its own right, so it shows up wherever routes are listed and takes part in [match ordering](/docs/request-lifecycle) at the point where `.alias()` is called — which is why you can keep all your aliases together (for example, at the very bottom) without changing behavior. The target must already be registered, referenced by its **exact** pattern string. Both patterns must use the **same HTTP method** and declare an **identical set of param names** (their order doesn’t matter). Anything else is a configuration error, thrown at setup time: ```ts zen.alias("GET /api/posts/", "GET /posts/"); // ❌ Config error: params differ ({roomId} vs {id}) zen.alias("POST /api/posts/", "GET /posts/"); // ❌ Config error: aliases must use the same HTTP method ``` ## Query strings [#query-strings] Query string parameters are available via `q`. They are always optional strings. ```ts // GET /api/posts?sort=newest&limit=10 zen.route("GET /api/posts", ({ q }) => { // q.sort = "newest" // q.limit = "10" // q.other = undefined }); ``` If a query param appears multiple times, only the first value is captured. For full control, use `url.searchParams` directly. ## Handler arguments [#handler-arguments] Every route handler receives a single object with the following properties: | Property | Description | | -------- | ---------------------------------------------------------------------------------- | | `req` | The original, unmodified Request | | `url` | Parsed URL (equivalent to `new URL(req.url)`) | | `ctx` | Value returned by your [`getContext`](/docs/authorization#the-getcontext-function) | | `auth` | Value returned by your [`authorize`](/docs/authorization#the-authorize-function) | | `p` | Typed route params | | `q` | Query string params | | `body` | Validated request body (if schema is provided) | ## Return values [#return-values] Handlers can return a JSON object and Zen Router will serialize it with a `200` status automatically. It [must be an object](/docs/principles#maintainable), not an array or scalar. For other response types, see [response helpers](/docs/response-helpers). Simple value json Response abort Error ```ts zen.route( "GET /example", () => { // Returning a simple object → JSON response return { items: [1, 2, 3] }; } ); ``` ```ts import { json } from "@liveblocks/zenrouter"; zen.route( "GET /example", // Explicit JSON response () => json({ items: [1, 2, 3] }) ); ``` ```ts zen.route( "GET /example", () => { // Full control over the response return new Response(JSON.stringify({ items: [1, 2, 3] }), { headers: { "Content-Type": "application/json" }, }); } ); ``` ```ts zen.route( "GET /example", () => { // Returns → 404 Not Found with body { "error": "Not Found" } abort(404); // Recommended } ); ``` ```ts zen.route( "GET /example", () => { // Returns → 500 Internal Server Error with body { "error": "Internal Server Error" } throw new Error("Oops"); } ); ``` # Supported runtimes Both `ZenRouter` and `ZenRelay` expose a `.fetch` method that is a standard `(request: Request) => Promise` handler. This makes them compatible with any runtime that supports the Web `Request`/`Response` API. ## Cloudflare Workers [#cloudflare-workers] Simple! Just export the router as the default export. Cloudflare Workers calls `.fetch` automatically. Single router Multiple routers ```ts title="src/worker.ts" import { ZenRouter } from "@liveblocks/zenrouter"; const zen = new ZenRouter({ ... }); zen.route("GET /api/health", () => ({ status: "ok" })); zen.route("GET /api/posts/", async ({ p }) => { ... }); zen.route("POST /api/posts", bodySchema, async ({ body }) => { ... }); // [!code highlight] // Just use a default export in your worker file // [!code highlight] export default zen; ``` ```ts title="src/worker.ts" import { ZenRelay } from "@liveblocks/zenrouter"; import { zen as authRoutes } from "./routes/auth"; import { zen as apiRoutes } from "./routes/api"; import { zen as adminRoutes } from "./routes/admin"; const app = new ZenRelay(); app.relay("/auth/*", authRoutes); app.relay("/api/admin/*", adminRoutes); app.relay("/api/*", apiRoutes); // [!code highlight] // Just use a default export in your worker file // [!code highlight] export default app; ``` ## Bun [#bun] Simple! Just pass `.fetch` to `Bun.serve()`: Single router Multiple routers ```ts title="src/index.ts" import { ZenRouter } from "@liveblocks/zenrouter"; const zen = new ZenRouter({ ... }); zen.route("GET /api/health", () => ({ status: "ok" })); zen.route("GET /api/posts/", async ({ p }) => { ... }); zen.route("POST /api/posts", bodySchema, async ({ body }) => { ... }); // [!code highlight] // Pass zen.fetch to Bun.serve() // [!code highlight] Bun.serve({ fetch: zen.fetch, port: 8000 }); ``` ```ts title="src/index.ts" import { ZenRelay } from "@liveblocks/zenrouter"; import { zen as authRoutes } from "./routes/auth"; import { zen as apiRoutes } from "./routes/api"; import { zen as adminRoutes } from "./routes/admin"; const app = new ZenRelay(); app.relay("/auth/*", authRoutes); app.relay("/api/admin/*", adminRoutes); app.relay("/api/*", apiRoutes); // [!code highlight] // Pass app.fetch to Bun.serve() // [!code highlight] Bun.serve({ fetch: app.fetch, port: 8000 }); ``` ## Node.js [#nodejs] Node.js uses `IncomingMessage`/`ServerResponse` instead of the Web `Request`/`Response` API, so you need an adapter. We recommend [`@whatwg-node/server`](https://www.npmjs.com/package/@whatwg-node/server): Single router Multiple routers ```ts title="src/index.ts" import { createServer } from "node:http"; import { createServerAdapter } from "@whatwg-node/server"; import { ZenRouter } from "@liveblocks/zenrouter"; const zen = new ZenRouter({ ... }); zen.route("GET /api/health", () => ({ status: "ok" })); zen.route("GET /api/posts/", async ({ p }) => { ... }); zen.route("POST /api/posts", bodySchema, async ({ body }) => { ... }); // [!code highlight] const server = createServer(createServerAdapter(zen.fetch)); // [!code highlight] server.listen(8000); ``` ```ts title="src/index.ts" import { createServer } from "node:http"; import { createServerAdapter } from "@whatwg-node/server"; import { ZenRelay } from "@liveblocks/zenrouter"; import { zen as authRoutes } from "./routes/auth"; import { zen as apiRoutes } from "./routes/api"; import { zen as adminRoutes } from "./routes/admin"; const app = new ZenRelay(); app.relay("/auth/*", authRoutes); app.relay("/api/admin/*", adminRoutes); app.relay("/api/*", apiRoutes); // [!code highlight] const server = createServer(createServerAdapter(app.fetch)); // [!code highlight] server.listen(8000); ```