Express
Mount a Kizuna API on an Express 5 application.
@kizunajs/express serves a Kizuna API from an Express 5 application. It handles routing, request validation, body parsing, and error formatting, all driven by what you declared.
Requires Express ≥ 5.
pnpm add @kizunajs/express@beta expressbun add @kizunajs/express@beta expressnpm install @kizunajs/express@beta expressThe adapter
expressAdapter() is what you name in kizuna.config.ts. Naming it is what gives every handler on this API Express's own req and res, typed.
Declare a route
import { k } from '../k';
export const users = k.routes('users', {
listUsers: k
.route({
method: 'GET',
path: '/users',
query: ListUsersQuerySchema,
responses: {
200: UserListSchema,
},
})
.handler(async ({ query }) => ({
status: 200,
body: {
users: await db.users.findMany({
skip: (query.page - 1) * query.limit,
take: query.limit,
}),
total: await db.users.count(),
},
})),
getUser: k
.route({
method: 'GET',
path: '/users/:id',
responses: {
200: UserSchema,
404: ProblemDetailsSchema,
},
})
.handler(async ({ params }) => {
const user = await db.users.findById(params.id);
if (!user) {
return {
status: 404,
body: {
detail: 'Not found',
},
};
}
return {
status: 200,
body: user,
};
}),
});Name Express in your config
import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { users } from './src/routes/users';
export default defineConfig({
adapter: expressAdapter(),
routes: {
users,
},
});Mount it
import express from 'express';
import kizuna from '../kizuna.config';
const app = express();
app.use(express.json());
kizuna.api.mount(app);
app.listen(3000);Handler context
Each handler receives { params, query, body, headers, req, res }. The req and res are the native Express objects, useful when you need to read cookies, set custom headers, or stream a response.
.handler(async ({ params, req, res }) => {
console.log(req.ip);
res.setHeader('x-custom', 'value');
return {
status: 200,
body: await db.users.findById(params.id),
};
});Guards
A guard sits on the identity it authenticates, and receives the credential extracted and typed, alongside the native req/res. See the Authentication guide.
import { k } from './k';
export const user = k.identity
.bearer({
context: z.object({
userId: z.string(),
}),
})
.guard(async ({ bearer, deny, req }) => {
const session = bearer ? await verifySession(bearer.token) : undefined;
if (!session) {
return deny({
status: 401,
body: {
detail: 'Unauthorized',
},
});
}
return {
userId: session.userId,
};
});Middleware
For request-scoped values handlers need, use request context. Rate limiting, multipart parsing and the rest go in Express's own app.use. Authentication belongs in guards.
Options
kizuna.api.mount(app, {
responseValidation: false,
});| Option | Default | Description |
|---|---|---|
responseValidation | false | Validate handler return values against response schemas, surfacing as 500 on mismatch. Enable in development. |
formatError | none | Reshape error (>= 400) response bytes for migrating clients. Most don't need it (use Problem Details extension members). See Migrating an existing API. |
Type-safe request properties
Middleware that sets custom properties on req, such as a request id, needs declaration merging to extend the Express Request interface. Authentication data does not: guards hand handlers typed context directly.
declare global {
namespace Express {
interface Request {
requestId: string;
}
}
}Both middleware and handlers are then typed:
.handler(async ({ params, req }) => {
console.log(req.requestId);
// ...
});Method mismatches
If a path is matched but the method is not declared, the adapter returns 405 with an Allow header listing the supported methods. This follows RFC 9110 §15.5.6.
Multipart / file uploads
For routes with contentType: 'multipart/form-data', add a multipart middleware such as multer or busboy before the Kizuna handler. The adapter reads req.body as-is.
import multer from 'multer';
const upload = multer({
storage: multer.memoryStorage(),
});
app.post('/avatar', upload.single('file'), (req, res, next) => {
next();
});
kizuna.api.mount(app);Streams
A stream response is piped to res after flushHeaders(), so the client sees the headers before the first message. The generator's signal fires when res closes before the stream has finished.