Zen Router

Defining routes

Route patterns, path params, body validation, and query strings.

Routes are defined with a single string containing the HTTP method and the full path:

zen.route(
  "GET /hello/<name>",

  ({ p }) => `Hello, ${p.name}!`
);

Route params are enclosed in angle brackets. They are automatically URI-decoded and cannot be empty or optional.

Multiple params work as expected:

zen.route(
  "GET /api/rooms/<roomId>/users/<userId>",

  async ({ p }) => {
    // p.roomId and p.userId are both typed strings
  }
);

All route paths are fully qualified and greppable. 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.

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

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 compatible library:

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/<postId>/authors/<index>",
  //               ^^^^^^           ^^^^^
  //               string           number

  ({ p }) => ...
);

zen.route(
  "POST /api/posts/<postId>/tag/<color>",
  //                ^^^^^^       ^^^^^
  //                string       'red' | 'green' | 'blue'

  ({ p }) => ...
);

If any param fails this validation, a 400 Bad Request will be returned. See the Request lifecycle. For example, a request to /api/posts/123/tag/yellow would be rejected because yellow is not a valid color.

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.

import { z } from "zod";

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 };
  }
);

Zen Router supports any validation library that implements the Standard Schema spec. For use on Cloudflare Workers, we recommend decoders 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.

Query strings

Query string parameters are available via q. They are always optional strings.

// 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

Every route handler receives a single object with the following properties:

PropertyDescription
reqThe original, unmodified Request
urlParsed URL (equivalent to new URL(req.url))
ctxValue returned by your getContext
authValue returned by your authorize
pTyped route params
qQuery string params
bodyValidated request body (if schema is provided)

Return values

Handlers can return a JSON object and Zen Router will serialize it with a 200 status automatically. It must be an object, not an array or scalar. For other response types, see response helpers.

zen.route(
  "GET /example",

  () => {
    // Returning a simple object → JSON response
    return { items: [1, 2, 3] };
  }
);

On this page

Made withHeartby Liveblocks