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>",
asyncp=>
// 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:
importfrom "zod";
const zen = new ZenRouter
number
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.
importfrom "zod";
zen.route
"POST /api/posts",
z.objectstring
asyncauth, body=>
const post = awaitcreatePost
return
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.
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():
// Canonical version
zen.route
"POST /api/posts",
z.objectstring
asyncp, 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 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:
zen.alias"GET /api/posts/<postId>", "GET /posts/<id>"
// ❌ Config error: params differ ({roomId} vs {id})
zen.alias"POST /api/posts/<id>", "GET /posts/<id>"
// ❌ Config error: aliases must use the same HTTP methodQuery 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:
| Property | Description |
|---|---|
req | The original, unmodified Request |
url | Parsed URL (equivalent to new URL(req.url)) |
ctx | Value returned by your getContext |
auth | Value returned by your authorize |
p | Typed route params |
q | Query string params |
body | Validated 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
return1, 2, 3