Routes
Declare a route, the schemas it takes and answers with, and the handler that serves it.
A route is a method, a path, the schemas it takes and answers with, and the handler that serves it. Everything else is read from that: defineConfig assembles your routes into the api you mount, and the generators write your OpenAPI document and your clients from the same declarations.
Create the surface
Construct it once, usually in src/k.ts, and export k. Its type parameter is the Config that kizuna generate writes from your kizuna.config.ts, and that is what types everything a handler receives and checks every name you write.
Declare your groups beside it, so routes have somewhere to be filed. Each group becomes a name k.routes offers, and a tag in the OpenAPI document.
Declare your routes
k.route takes what the route is, and .handler() takes what answers it. k.routes.users files them under the Users group, at the bottom of the file:
The handler's query and body are typed from the declaration right above it, and its return is checked against that route's responses. See Routes for everything a handler receives.
A route only a generator or a client reads can skip .handler(). One your config serves cannot.
Route fields
| Field | Required | Description |
|---|---|---|
method | Yes | GET POST PUT PATCH DELETE HEAD OPTIONS |
path | Yes | URL path. Use :name for path parameters |
responses | Yes | Response schemas keyed by status code |
pathParams | No | Path parameter schema, keyed by placeholder name |
body | No | Request body schema |
query | No | Query string schema |
headers | No | Request headers schema |
contentType | No | 'application/json' (default), 'multipart/form-data', 'application/x-www-form-urlencoded' |
summary | No | Short description shown in OpenAPI, and what a model reads when the route publishes as a tool |
description | No | The longer account, for OpenAPI and for a model |
groups | No | More groups to list the route under, beside the one k.routes files it in |
auth | No | The identity this route requires, and the roles or permissions it accepts. Required once your config declares an identity, so public takes an explicit false. See Access Control |
tool | No | Publish this route as a tool a model can call |
rawBody | No | true to receive the body as sent, as rawBody, for checking a signature. See below |
hidden | No | true to leave it out of the generated clients and the OpenAPI document. See below |
deprecated | No | true, or the migration message. See Deprecations |
cache | No | The Cache-Control policy its responses send. See Caching |
Typing path parameters
params is typed from the path itself, so /users/:id gives the handler params.id as a string with nothing else to declare. Add pathParams when you want it parsed or narrowed:
Its keys are checked against the path, so a name the path does not carry is a compile error naming the path it looked in:
Each value must be a scalar, since a path segment arrives as one string. Structured values belong in query. To parse one into something richer, use z.string().transform(...):
Handler arguments
Each handler receives an object with the validated inputs for that route:
| Property | Available when |
|---|---|
params | Route path has :param placeholders |
query | Route has a query schema |
body | Route has a body schema |
headers | Route has a headers schema |
throwError | Always |
auth | Route's auth names an identity |
requestContext | Config declares request context |
plugins | Config declares plugins |
jobs | Config declares jobs |
req, res | Express adapter |
c | Hono adapter |
request, reply | Fastify adapter |
request | Next.js adapter |
Which framework object arrives is decided by the adapter your config names, so a handler reads req or c without importing either framework's types.
Identity context
When a route's auth names an identity, the handler receives that identity's context under auth, keyed by the identity's name. The context is whatever its guard returned, typed by the identity's context schema, with role beside it when the identity declares roles, and permissions when those roles come from a catalog. A row check goes here too, with auth in hand. See Access Control.
A route requiring several identities receives all of them under auth:
A route whose auth names roles or requires is checked before the handler runs, so the handler never asks whether the caller may be there. It reads the role when it needs it for something else, such as an audit line:
Values declared as request context arrive the same way, under requestContext on every route.
Return type
Return { status, body } matching one of the route's declared responses. TypeScript enforces that the status and body match.
For routes with typed response headers, include headers too:
A status declared with stream takes an async generator function as body. Kizuna sends the status and headers, then writes each yield as it happens:
signal fires when the client goes away. throwError takes no streamed status.
A response with a cache policy sends its Cache-Control whenever your handler returns that status. The declaration wins, so returning a cache-control of your own does not change it.
Error responses
Use ProblemDetailsSchema from kizunajs/schemas for error status codes. This is the same shape deny() produces in guards, and the one every built-in Kizuna error uses.
In the handler, throwError returns a typed error response. It takes the same { status, body } as a normal return but throws internally, which is useful in branching logic where you want to bail out early:
TypeScript enforces the status and body the same way it does for a return value.
Validation errors
When a request body or query fails schema validation, Kizuna returns a 400 before the handler runs:
Each issue includes a code that tells you why the field failed, not just that it failed. Codes come from Zod's built-in validation:
| Code | When it fires |
|---|---|
invalid_type | Wrong type or missing required field |
too_small | Below min() / minLength() |
too_big | Above max() / maxLength() |
invalid_string_format | Failed email(), url(), uuid(), etc. |
unrecognized_keys | Extra keys when using strict() |
not_multiple_of | Failed multipleOf() |
custom | .refine() or .superRefine() check |
User-defined checks (.refine()) report as custom by default. To emit a typed code instead, like invalid_phone_number above, declare it under validation.issueCodes on defineConfig and raise it with k.issue. It then shows up in errors[].code as a typed literal on both the server and the generated client, not a bare string.
The message and path fields come from your Zod schemas, custom messages (z.string().min(1, 'Name is required')) included.
This 400 is reflected in the fetch client return type and the OpenAPI spec for any route that declares a body or query schema.
If you declare your own 400 response, the client sees a union of both your type and Kizuna's ValidationError. Use isValidationError on the client to distinguish them.
Query, path, and header coercion
Values in the URL and in headers always arrive as strings. Kizuna coerces them to the types you declare, so both the client and the server see the parsed value:
| Declare | Accepts on the wire | You get |
|---|---|---|
z.number() / z.int() | "42" | 42 |
z.boolean() | "true" / "false" | true / false |
z.bigint() | "9007199254740993" | 9007199254740993n |
z.date() | an ISO 8601 string | Date |
Arrays of these coerce too (z.array(z.number())). The fetch client serializes Date and bigint values for you, so a z.date() query param round-trips without manual formatting. Request bodies are sent as JSON and keep their types.
Because coercion is built in, z.coerce is not supported, and k.routes throws if any schema uses it. Declare the plain schema (z.number(), z.date(), z.bigint()). To receive a different type than you send, use z.string().transform(...).
Response schemas
One Zod schema per status code:
Use z.void() for a status that carries no body:
To type response headers, give the status a { body, headers } object:
To send a response piece by piece, declare stream in place of body, with the schema of one message, or a record of event name to schema:
The handler then yields messages instead of returning one body, and the fetch client reads them with for await. See Streaming.
One route per file
A route and its handler travel together, so a route that outgrows its group moves to its own file and k.routes still groups it:
Nothing runs at import
kizuna generate imports your route files to read them, so whatever a module does at the top level, it does on every generate. Importing a database client is fine. Opening a connection, reading a file, or throwing on a missing environment variable is not, so keep that inside the handler.
Verifying signed requests
Services that call your API, like Resend or Stripe webhooks, sign each request so you can prove it came from them. The signature covers the body exactly as it was sent, so a route that checks one sets rawBody: true and receives that text beside the validated body:
rawBody is the body as a string, before any parsing. Checking the signature against it, rather than against body, is what makes the check hold, since re-serialising the JSON can change a single byte and break it. Every adapter keeps it for you. On Express it takes one extra step only if your app also runs express.json() ahead of Kizuna; see alongside your own Express routes.
Non-JSON responses
For non-JSON responses, set contentType to send the body as-is instead of JSON. Responses default to application/json, errors to application/problem+json, and a stream to text/event-stream. Declare a non-JSON body as z.string():
For binary responses, use BinarySchema for a body of raw bytes (Uint8Array/Buffer), such as a PDF download:
Hidden routes
Every route lands in every client your config generates, and in the OpenAPI document. Some routes aren't for your apps at all: Stripe calls your webhook route, and your load balancer calls the health check. Mark those hidden:
The server answers it as always. It's left out of every generated client and, by default, the OpenAPI document, so no app gets a check() method nobody would call.
It can't be a tool
A hidden route stays out of MCP too. hidden and tool on the same route is a type error, and a stream can't name a hidden route under tools, so a webhook or a health check never becomes something a model can call.
It isn't access control
A hidden route is still served, and anyone who knows its URL can call it. Its auth is what decides who may.
kizuna routes marks hidden routes, and kizuna diff reports a route becoming hidden as breaking for your clients' callers, even though the server still serves it.
Going further
Naming schemas with models
By default the generators inline each schema wherever it appears. Use Kizuna.model to give a schema a name they can reuse:
- OpenAPI: extracted into
components.schemas.User, and every usage becomes a$ref - Swift: emitted as a shared
public struct User - TypeScript: emitted as
API.Userin the generated client (see Named types)
Splitting routes across files
Large APIs split into groups, nested as deep as the API needs. Declare them once with k.groups and name them on defineConfig:
Each file declares its routes, then files them into a group at the bottom. A nested group is reached through its parent:
An index.ts beside them lists the files, in any order, so the config has one import to make:
Each route's key is its group's path, then its name: listMembers is workspaces.members.listMembers, and clients call client.workspaces.members.listMembers(). An object inside the group call adds a level without adding a group:
That is client.projects.settings.update(), filed under Projects in the docs. Two routes that claim the same key make defineConfig throw.
See Project Structure for where the rest of the pieces go.
Each top-level group becomes an OpenAPI tag, with its title as the tag's name and its description in the tag definition. OpenAPI 3.1 has no nested tags, so a route in a nested group is listed under the group at the top of its path: listMembers shows under Workspaces. A route can also list more groups by dotted path:
Deprecating routes and fields
Set deprecated on a route, either true or a migration message:
Fields declare it in their Zod metadata, with the same true | string shape:
Your editor, the OpenAPI spec, and the Swift and Kotlin clients pick both up. A route also takes a deprecation date and a sunset timestamp, both ISO 8601, and every response from it then announces them in the Deprecation and Sunset headers. See Deprecations.
Reference
Next steps
- Config to assemble your routes and serve them
- Authentication for the identities a route's
authnames