Today we’re open sourcing [Zen Router](https://zenrouter.liveblocks.io), an
opinionated HTTP router powering Liveblocks over the past two years, handling
billions of requests per month. It features typed path params, built-in body
validation, and a clean model for auth. Zen Router is useful in any project that
needs an HTTP router, and is compatible with Cloudflare Workers, Bun, Node.js,
and every other modern JavaScript runtime.

This release marks the second stage of our effort to make the Liveblocks stack
available to everyone, which began last week with our
[sync engine and dev server](/blog/open-sourcing-the-liveblocks-sync-engine-and-dev-server),
and continues today with Zen Router.

<GitHubStars
  className="mt-4 mb-6"
  org="liveblocks"
  name="zenrouter"
  url="https://github.com/liveblocks/zenrouter"
/>

## Why we built our own router

The Liveblocks back end runs on Cloudflare Workers, a runtime where bundle size
really matters. Ever since Liveblocks started, we’ve relied on
[`itty-router`](https://github.com/kwhitley/itty-router), the stock Cloudflare
recommendation for Workers. It’s marketed as _“an ultra-tiny API microrouter”_,
and that’s exactly what it is. It’s good at routing, but being so small, it
comes without any other functionality. Body validation, route param URI
decoding, auth, error handling, and CORS are entirely your responsibility.

`itty-router` is honest about being a minimal primitive, but each missing
feature meant we had to build our own, or use a middleware library for it. Over
time, these middlewares started to pile up, and middleware ordering became a
nightmare.

Ironically, our feature-complete router library produced a smaller bundle size
than `itty-router` combined with our middleware stack.

## Composing routers

A typical back end serves endpoints to different audiences, each with its own
auth requirements: your main API might need token auth, admin routes require
stricter access control, webhook receivers verify request signatures, and some
routes like login need no auth at all.

We typically want to group routes with the same auth requirements together. And
this is at the heart of Zen Router’s design: each such group is a `ZenRouter`
instance.

At the outermost layer, we use
[`ZenRelay`](https://zenrouter.liveblocks.io/docs/composing-routers) to bind
them all together. This is just a thin dispatch layer that looks at an incoming
request, selects which router gets to handle it purely based on the prefix, and
dispatches it.

```ts file="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";

// At the top level, Zen Relay "just" relays incoming requests to
// different Zen Router instances, purely based on URL prefix
const app = new ZenRelay();
app.relay("/auth/*", authRoutes); // No auth (public)
app.relay("/api/admin/*", adminRoutes); // Uses private auth
app.relay("/api/*", apiRoutes); // Uses Bearer token auth, requires CORS
app.relay("/webhooks/*", webhookRoutes); // Uses signature verification

// Now just call app.fetch(request) in your runtime’s request handler,
// and it will do the rest
export default app;
```

There is **no fall-through** here like you sometimes see in other routers. For
example, if an incoming request is for `/api/admin/users/123` then only the
admin router `/api/admin/*` will handle it. If that router doesn’t have a
matching route, it will result in a 404 response. The `/api/*` router will
deliberately **not** get a chance to handle it. This is because each router has
its own auth requirements, so it’s important that routers are fully isolated
from each other.

The use of `ZenRelay` is optional. If your back end only needs one `ZenRouter`
instance, you don’t need `ZenRelay` at all and you can use that `ZenRouter`
instance directly.

## What a route handler looks like

This is what a route inside `apiRoutes` from the example above might look like:

```ts file="src/routes/api.ts"
export const zen = new ZenRouter({
  /* config */
});

// e.g. PATCH https://example.com/api/users/abc123?notify=true
zen.route(
  "PATCH /api/users/<userId>",

  // Validates input body
  z.object({ name: z.string() }),

  // Handler
  async ({ req, ctx, auth, body, p, q }) => {
    //
    // req  = original, unmodified Request
    // ctx  = value returned by your getContext
    // auth = value returned by your authorize (typically the current user)
    // body = validated request input body, eg { name: "Alicia" }
    // p    = path params object, eg { userId: "abc123" }
    // q    = query string params object, eg { notify: "true" }
    //
  }
);
```

The rest of this post breaks down each of these pieces.

## Defining a router

Each router has a set of routes, and two important hooks: `getContext` and
`authorize`. The return values of these functions become available in the
handler.

```ts file="src/routes/api.ts"
export const zen = new ZenRouter({
  // Optional context is any metadata you want to attach to an incoming request
  getContext: (req) => ({ foo: "bar" }),

  // Authorization is mandatory
  authorize: async ({ req }) => {
    const token = req.headers.get("Authorization");
    const user = await db.getUserByToken(token);
    if (!user) return false; // → 403 Forbidden

    // Return any truthy value to allow
    // For example, the full authorized user object
    return { currentUser: user };
  },
});

zen.route("GET /api/users/me", ({ ctx, auth }) => {
  return {
    id: auth.currentUser.id,
    name: auth.currentUser.name,
    metadata: ctx.foo,
  };
});

zen.route("GET /api/users/<userId>", async ({ p }) => {
  const user = await db.getUserById(p.userId);
  return {
    id: user.id,
    name: user.name,
  };
});
```

**Authorization is mandatory.** Every router _must_ provide an `authorize`
function that will be evaluated before any route handler runs. Return a falsy
value or throw to reject the request with a 403 response. Return any truthy
value to allow the request, and that value becomes available as `auth` in the
handler.

**Providing `getContext` is optional.** You can return any value from it, and it
will be made available to handlers as `ctx`. This is a great place to pass down
database connections or structured loggers.

{/* prettier-ignore */}
<p><strong>Path params are typed automatically.</strong> A route pattern like <code className="whitespace-nowrap">GET /api/users/&lt;userId&gt;</code> makes <code className="whitespace-nowrap">p.userId</code> available as a typed string in the handler, inferred from the pattern at compile time. No extra type declarations needed. You can also register <a href="https://zenrouter.liveblocks.io/docs/routes#route-param-schema">custom param schemas</a> at the router level to transform params to other types or restrict their allowed values.</p>

## Body validation

Routes that accept a request body _must_ declare a validator as their second
argument. The body is validated before your code executes, so `body` is already
parsed and fully typed.

```ts
import { z } from "zod";

zen.route(
  "PATCH /api/users/me",

  // Expected incoming request body shape
  // +++
  z.object({ name: z.string() }),
  // +++

  async ({ auth, body }) => {
    await db.updateUser(auth.currentUser.id, {
      // +++
      name: body.name,
      // +++
    });
    return { ok: true };
  }
);
```

If the body doesn’t match the schema, Zen Router returns an HTTP 422
Unprocessable Entity response with a human-readable error message.

At Liveblocks, we use [decoders](https://decoders.cc/) for validation because
it’s more lightweight and produces more beautiful error messages than Zod.
However, Zen Router supports any validation library that implements the
[Standard Schema](https://github.com/standard-schema/standard-schema) spec.

## What else is built in

### CORS

{/* prettier-ignore */}
<p>CORS support is a first-class feature. Passing
<code className="whitespace-nowrap">{`{ cors: true }`}</code> to the router
enables it across all routes, and Zen Router will correctly handle all <code
className="whitespace-nowrap">OPTIONS</code> preflight requests automatically.
For more control, pass a [configuration object](https://zenrouter.liveblocks.io/docs/cors) instead to set allowed origins,
credentials, exposed headers, and more. Either way, CORS is per-router, not
per-route, which matches how you’d almost always want it. In our case, only the
main API router needs CORS since it’s the only one browsers talk to
directly.</p>

### Error handling

From inside any handler, you can short-circuit with an error response:

```ts
import { abort } from "@liveblocks/zenrouter";

// Returns { "error": "Not Found" } with status 404
abort(404);
```

The error shape that `abort()` produces can be customized centrally at the
router level with
[`onError` and `onUncaughtError`](https://zenrouter.liveblocks.io/docs/error-handling)
hooks, so individual handlers don’t need to worry about it.

### Response helpers

Handlers can return a plain object and Zen Router will serialize it as JSON
automatically. For other response types, a set of helpers is included:

- `json(value, status?, headers?)`: JSON response with correct content type
- `html(content, status?, headers?)`: HTML response
- `textStream(iterable)`: Streaming text response from a string generator

## OpenTelemetry support

Zen Router supports
[OpenTelemetry](https://zenrouter.liveblocks.io/docs/opentelemetry). Use your
favorite OpenTelemetry package, then tell Zen Router how to get the current
span, and Zen Router will automatically set the matched route pattern and path
params as span attributes. This means you get per-route observability for free
with a single config option.

## Request lifecycle

Here’s the full picture of how an incoming request is processed by Zen Router:

<Figure>
  <Image
    src="/images/blog/introducing-zen-router/zenrouter-request-lifecycle.png"
    alt="Zen Router request pipeline diagram"
    width={1832}
    height={3012}
    quality={90}
  />
</Figure>

## Unconventional design choices

Zen Router was founded on [the principles laid out in its
documentation][principles], optimized for type-safety, long-term
maintainability, and joy to use. Zen Router values explicitness over
implicitness, always, everywhere.

Some of these principles may feel unconventional at first, but they have really
made endpoint development a joy to us.

- **Auth is mandatory.** For public routes, explicitly opt out.
- **All route paths are fully qualified and greppable.** No prefix-based
  sub-routers. A grep for `/api/users` returns every route that touches that
  path, unambiguously.
- **All route paths include the HTTP method.** The method and path are a single
  string, which lets the router automatically return `405 Method Not Allowed`
  when a URL matches but the method doesn’t.
- **No middlewares.** Auth and context are the only hooks. No middleware
  ordering, no implicit state.
- **No request monkey patching, ever.** Metadata, the current user, validated
  bodies, route params, and query params are passed alongside the original
  request, not attached to it.
- **No fall-through between routers.** Each relay prefix maps to exactly one
  router.
- **CORS is built-in,** not bolted on as a middleware.
- **Throwing is first-class flow control.** Call `abort()` to short-circuit the
  handler and return an error response.

## Get Started

Install the package now to get started.

<InstallCommand
  options={[
    { label: "npm", command: "npm install @liveblocks/zenrouter" },
    { label: "yarn", command: "yarn add @liveblocks/zenrouter" },
    { label: "pnpm", command: "pnpm add @liveblocks/zenrouter" },
    { label: "bun", command: "bun add @liveblocks/zenrouter" },
  ]}
/>

### Documentation

Our new website covers the full API including context setup, auth functions,
custom error handling, and how to wire the router into different runtimes

<ButtonLink
  href="https://zenrouter.liveblocks.io"
  size="lg"
  openInNewWindow={true}
>
  Zen Router Documentation
</ButtonLink>

### Feedback

We’d love your feedback—open an issue, start a discussion on
[GitHub](https://github.com/liveblocks/liveblocks), or reach out to us on
[Discord](/discord) or [X](https://x.com/liveblocks).

[principles]: https://zenrouter.liveblocks.io/docs/principles