Quickstart

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

Installation

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

pnpm add kizunajs@beta zod @kizunajs/next@beta && pnpm add -D @kizunajs/cli@beta
bun add kizunajs@beta zod @kizunajs/next@beta && bun add -d @kizunajs/cli@beta
npm install kizunajs@beta zod @kizunajs/next@beta && npm install --save-dev @kizunajs/cli@beta

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 { z } from 'zod';
import { ProblemDetailsSchema } from 'kizunajs/schemas';
import { k } from '../k';

export const UserSchema = 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.

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
bun add @kizunajs/fetch@beta
npm install @kizunajs/fetch@beta
kizuna.config.ts
import { fetchClient } from '@kizunajs/fetch/server';

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.users.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

On this page