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@betabun add kizunajs@beta zod @kizunajs/next@beta && bun add -d @kizunajs/cli@betanpm install kizunajs@beta zod @kizunajs/next@beta && npm install --save-dev @kizunajs/cli@betaEnable strict in your tsconfig.json. Kizuna's inference depends on it.
{
"compilerOptions": {
"strict": true
}
}Start with the config
kizuna.config.ts sits at the root of your app. Start with your framework's adapter:
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
{
"scripts": {
"kizuna:generate": "kizuna generate",
"kizuna:check": "kizuna generate --check"
}
}pnpm kizuna:generateThat 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:
After changing a route, run `pnpm kizuna:generate`.Declare the routes
Keep one k for the API and export it:
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:
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:
import { users } from './src/routes/users';
export default defineConfig({
adapter: nextAdapter(),
routes: {
users,
},
});Mount it
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@betabun add @kizunajs/fetch@betanpm install @kizunajs/fetch@betaimport { fetchClient } from '@kizunajs/fetch/server';
export default defineConfig({
adapter: nextAdapter(),
routes: {
users,
},
clients: [
fetchClient({
output: './src/lib/api-client.generated.ts',
}),
],
});pnpm kizuna:generateWrap it once with the base URL you serve on:
import { createClient } from './api-client.generated';
export const apiClient = createClient({
baseUrl: 'http://localhost:3000/api',
});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:
'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