` 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
# 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);
```