Quickstart
Define a contract, implement a server, and call it from a typed client in 8 minutes.
A ts-kizuna project has three parts:
- The contract holds your routes and their Zod schemas, in a package both sides import.
- The server holds handlers that implement those routes, mounted on your framework.
- The client is built from the same contract, so it knows every route, input, and response.
Installation
Install @ts-kizuna/core, the package that defines routes and contracts.
pnpm add @ts-kizuna/core zodbun add @ts-kizuna/core zodnpm install @ts-kizuna/core zodts-kizuna requires Zod 4. It leans on Zod 4 features directly, so older versions won't work.
Enable strict in your tsconfig.json. ts-kizuna's inference depends on it.
{
"compilerOptions": {
"strict": true
}
}Define the routes
Bind your API surface once with new Kizuna() and export k, then group your routes with k.routes. Keep each piece in its own file (a package both your server and client import).
import { Kizuna } from '@ts-kizuna/core';
export const k = new Kizuna();new Kizuna() takes optional tags, identities, request contexts and validation settings. See new Kizuna() for the full options.
Each route pairs a method and path with Zod schemas:
import { z } from 'zod';
import { ProblemDetailsSchema } from '@ts-kizuna/core/schemas';
import { k } from './k';
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.email(),
});
export const usersRoutes = k.routes({
listUsers: {
method: 'GET',
path: '/users',
query: z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(10),
}),
responses: {
200: z.object({
users: z.array(UserSchema),
total: z.number(),
}),
},
},
createUser: {
method: 'POST',
path: '/users',
body: z.object({
name: z.string().min(1),
email: z.email(),
}),
responses: {
201: UserSchema,
400: ProblemDetailsSchema,
},
},
getUser: {
method: 'GET',
path: '/users/:id',
responses: {
200: UserSchema,
404: ProblemDetailsSchema,
},
},
});Bundle them into a contract
The contract is the one object you hand to both the server and the client.
import { k } from './k';
import { usersRoutes } from './routes';
export const contract = k.contract({
routes: {
users: usersRoutes,
},
});Implement the server
Pick an adapter, here Next.js. new KizunaServer() binds the contract to a server handle.
pnpm add @ts-kizuna/nextbun add @ts-kizuna/nextnpm install @ts-kizuna/nextserver.router takes one function per route. Inputs arrive validated and typed, and return values are checked against the contract's declared responses.
import { KizunaServer } from '@ts-kizuna/next';
import { contract } from '@shared/contract';
export const server = new KizunaServer(contract);import { server } from './server';
export const router = server.router({
users: {
listUsers: async ({ query }) => {
const users = await db.users.findMany({
skip: (query.page - 1) * query.limit,
take: query.limit,
});
return {
status: 200,
body: {
users,
total: await db.users.count(),
},
};
},
createUser: async ({ body }) => {
const existing = await db.users.findByEmail(body.email);
if (existing) {
return {
status: 400,
body: {
detail: 'Email already in use',
},
};
}
const user = await db.users.create(body);
return {
status: 201,
body: user,
};
},
getUser: 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,
};
},
},
});server.api binds the router into the API object the adapter mounts.
import { server } from './server';
import { router } from './router';
export const api = server.api({
router,
});Mount it on a catch-all route.
import { api } from '@/server/api';
export const { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS } = api.mount({
basePath: '/api',
});Call it from a typed client
The client is built from the same contract, so every route, input, and response is already typed.
pnpm add @ts-kizuna/fetchbun add @ts-kizuna/fetchnpm install @ts-kizuna/fetchimport { KizunaClient } from '@ts-kizuna/fetch';
import { contract } from '@shared/contract';
export const apiClient = new KizunaClient(contract, {
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>
);
}Writing is the same, and the status tells you which body you got:
'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);
}Every field is typed straight from the contract with no extra work, including query.page, body.name, and the response shape.
Next steps
- Building an API covers the contract, router, and mounting in depth
- Auth declares identities and per-route auth on the contract, and guards, handler types, OpenAPI security, and client access checks all 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