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 express

The 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

src/routes/users.ts
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

kizuna.config.ts
import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { users } from './src/routes/users';

export default defineConfig({
    adapter: expressAdapter(),
    routes: [users],
});

Mount it

src/index.ts
import express from 'express';
import kizuna from '../kizuna.config';

const app = express();

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.

src/routes/users.ts
.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.

src/identities.ts
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

src/index.ts
kizuna.api.mount(app, {
    responseValidation: false,
    json: {
        limit: '5mb',
    },
});
OptionDefaultDescription
responseValidationfalseValidate handler return values against response schemas, surfacing as 500 on mismatch. Enable in development.
jsonExpress's defaultsThe options for express.json(), like limit and strict, used for Kizuna's own routes. Leave it out if the app runs its own express.json().
formatErrornoneReshape 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.

express.d.ts
declare global {
    namespace Express {
        interface Request {
            requestId: string;
        }
    }
}

Both middleware and handlers are then typed:

src/routes/users.ts
.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.

Alongside your own Express routes

An app with Express routes of its own usually runs express.json() for them. Kizuna works with that: its routes use the body express.json() already parsed. Set it up one of two ways.

Mount Kizuna first

Kizuna's routes answer before express.json() runs, so it only parses for yours:

src/index.ts
const app = express();

kizuna.api.mount(app);

app.use(express.json());
app.post('/legacy/import', importHandler);

Or keep the raw body

With express.json() registered first, pass keepRawBody as its verify option, so routes with rawBody: true still get the body as it was sent:

src/index.ts
import express from 'express';
import { keepRawBody } from '@kizunajs/express';
import kizuna from '../kizuna.config';

const app = express();
app.use(
    express.json({
        verify: keepRawBody,
    })
);

app.post('/legacy/import', importHandler);
kizuna.api.mount(app);

Without either, a rawBody route answers 500, and the log names the route and both fixes.

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.

src/index.ts
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.

Reference