Quickstart

Declare your routes, serve them on your framework, and call them from a typed client in 8 minutes.

Installation

Install kizunajs, your framework's adapter, Zod 4 for the schemas, and the CLI.

pnpm add kizunajs@beta @kizunajs/next@beta zod && pnpm add -D @kizunajs/cli@beta

2.0 is in pre-release

Kizuna installs from the beta tag until 2.0.0 ships. The 1.x line published under a different name and is no longer maintained, so it gets no fixes and no security backports.

Enable strict in your tsconfig.json. Kizuna's inference depends on it.

tsconfig.json
{
    "compilerOptions": {
        "strict": true
    }
}

Start with the config

kizuna.config.ts sits at the root of your app. Start with your framework's adapter:

kizuna.config.ts
import { defineConfig } from 'kizunajs';
import { nextAdapter } from '@kizunajs/next'; // or express, fastify, hono

export default defineConfig({
    adapter: nextAdapter(),
    typescript: {
        outputFile: './kizuna.types.ts',
    },
});

The adapter puts that framework's own request in every handler's args, typed.

Add the generate script

package.json
{
    "scripts": {
        "kizuna:generate": "kizuna generate",
        "kizuna:check": "kizuna generate --check"
    }
}
pnpm kizuna:generate

That writes the Config that types everything you declare next. Run it after every change, and kizuna:check in CI.

Say so where it gets read:

AGENTS.md
After changing a route, run `pnpm kizuna:generate`.

Declare the routes

Keep one k for the API and export it:

src/k.ts
import { Kizuna } from 'kizunajs';
import type { Config } from '../kizuna.types';

export const k = new Kizuna<Config>();

k is typed by the Config that kizuna:generate writes, so every name you use is checked against what your config declares. See new Kizuna().

Each route pairs a method and path with Zod schemas, and carries the handler that answers it:

src/routes/users.ts
import { Kizuna } from 'kizunajs';
import { z } from 'zod';
import { ProblemDetailsSchema } from 'kizunajs/schemas';
import { k } from '../k';

export const UserSchema = Kizuna.model({
    title: 'User',
    schema: z.object({
        id: z.string(),
        name: z.string(),
        email: z.email(),
    }),
});

export const users = k.routes({
    listUsers: k
        .route({
            method: 'GET',
            path: '/users',
            query: z.object({
                page: z.int().min(1).default(1),
                limit: z.int().min(1).max(100).default(10),
            }),
            responses: {
                200: z.object({
                    users: z.array(UserSchema),
                    total: z.number(),
                }),
            },
        })
        .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(),
            },
        })),
    createUser: k
        .route({
            method: 'POST',
            path: '/users',
            body: z.object({
                name: z.string().min(1),
                email: z.email(),
            }),
            responses: {
                201: UserSchema,
                400: ProblemDetailsSchema,
            },
        })
        .handler(async ({ body }) => {
            const existing = await db.users.findByEmail(body.email);
            if (existing) {
                return {
                    status: 400,
                    body: {
                        detail: 'Email already in use',
                    },
                };
            }
            return {
                status: 201,
                body: await db.users.create(body),
            };
        }),
    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: 'User not found',
                    },
                };
            }
            return {
                status: 200,
                body: user,
            };
        }),
});

The handler's query, body and params are typed from the declaration right above it, and its return is checked against that route's responses.

Kizuna.model names UserSchema, so it lands as User in the generated client and components.schemas.User in the OpenAPI document rather than being written out at each use.

Add them to the config:

kizuna.config.ts
import { users } from './src/routes/users';

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

Mount it

src/app/api/[...kizuna]/route.ts
import kizuna from '../../../../kizuna.config';

export const { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS } = kizuna.api.mount({
    basePath: '/api',
});

This is the one step that differs per framework. Express, Fastify and Hono each mount their own way.

Call it from a typed client

Add fetchClient to the config, pointed at the file you want written:

pnpm add @kizunajs/fetch@beta
kizuna.config.ts
import { fetchClient } from '@kizunajs/fetch';

export default defineConfig({
    adapter: nextAdapter(),
    routes: [users],
    clients: [
        fetchClient({
            output: './src/lib/api-client.generated.ts',
        }),
    ],
});
pnpm kizuna:generate

Wrap it once with the base URL you serve on:

src/lib/api-client.ts
import { createClient } from './api-client.generated';

export const apiClient = createClient({
    baseUrl: 'http://localhost:3000/api',
});
page.tsx
import { apiClient } from '@/lib/api-client';

export default async function UsersPage() {
    const { body } = await apiClient.listUsers({
        query: {
            page: 1,
            limit: 10,
        },
    });

    // body.users is User[]
    return (
        <ul>
            {body.users.map((user) => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
}

A route with more than one status narrows on status:

actions.ts
'use server';

import { apiClient } from '@/lib/api-client';

export async function createUser(name: string, email: string) {
    const result = await apiClient.users.createUser({
        body: {
            name,
            email,
        },
    });

    // result.status is 201 | 400, and TypeScript narrows the body from there
    if (result.status === 201) {
        return result.body.id;
    }

    throw new Error(result.body.detail);
}

Next steps

  • Routes covers declaring a route and everything its handler receives
  • Authentication declares identities and the guards on them, Access Control says who may call each route, and handler types and OpenAPI security follow
  • Adapters covers Express, Fastify, Hono, and Next.js setup and options
  • Fetch client covers headers, custom fetch, and error handling
  • OpenAPI generation serves a spec alongside your API