Adapters

Next.js

Mount a ts-kizuna API on a Next.js App Router catch-all route.

@ts-kizuna/next connects a ts-kizuna API to a Next.js App Router application via a single catch-all route file.

Requires Next.js ≥ 16 (App Router).

pnpm add @ts-kizuna/next
bun add @ts-kizuna/next
npm install @ts-kizuna/next

Setup

Wrap your next.config.ts with withKizuna:

next.config.ts
import type { NextConfig } from 'next';
import { withKizuna } from '@ts-kizuna/next/config';

const nextConfig: NextConfig = {
    // your config
};

export default withKizuna(nextConfig);

Then create a catch-all route at src/app/api/[...ts-kizuna]/route.ts. The adapter exports handlers for every HTTP method, which Next.js dispatches automatically.

src/server/server.ts
import { KizunaServer } from '@ts-kizuna/next';
import { contract } from '@shared/contract';

export const server = new KizunaServer(contract);
src/server/router/index.ts
import { server } from './server';

export const router = server.router({
    users: {
        listUsers: async ({ query }) => {
            const found = await db.users.findMany({
                skip: (query.page - 1) * query.limit,
                take: query.limit,
            });
            return {
                status: 200,
                body: {
                    users: found,
                    total: await db.users.count(),
                },
            };
        },
        createUser: async ({ body }) => {
            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: 'Not found',
                    },
                };
            }
            return {
                status: 200,
                body: user,
            };
        },
    },
});
src/server/api.ts
import { server } from './server';
import { router } from './router';

export const api = server.api({
    router,
});
src/app/api/[...ts-kizuna]/route.ts
import { api } from '../../../server/api';

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

The basePath option tells the adapter to strip the prefix when matching requests against the declared paths.

Handler context

Each handler receives { params, query, body, headers, request }. The request is the native NextRequest, useful when you need to read cookies or access other request properties.

getUser: async ({ params, request }) => {
    const token = request.cookies.get('session')?.value;
    const user = await db.users.findById(params.id);
    return {
        status: 200,
        body: user,
    };
},

Guards

One guard per identity. The credential arrives extracted and typed, alongside the native request. See the Auth guide.

src/server/guards.ts
import { server } from './server';

export const requireUser = server.guard('user', async ({ bearer, deny }) => {
    const session = bearer ? await verifySession(bearer.token) : undefined;
    if (!session) {
        return deny(401, 'Unauthorized');
    }
    return {
        userId: session.userId,
    };
});

Middleware

For request-scoped values handlers need, use Kizuna.requestContext. For other middleware, such as logging, rate limiting, and cache headers, use Next.js's own middleware, or the requestMiddleware option to run middleware after route matching. Authentication belongs in guards.

Options

Pass options to api.mount:

api.mount({
    basePath: '/api',
    responseValidation: false,
});
OptionDefaultDescription
basePath''Path prefix to strip when matching incoming paths
requestMiddleware[]Middleware run after route matching, before the handler. See requestMiddleware
responseValidationfalseValidate handler return values against response schemas. Enable in development.
onErrornoneMap a thrown error into a response (inbound migration seam); return an AdapterResult/Response to override the default 500. Also settable on server.api.

onError

Set onError on server.api (or pass it to api.mount) to send a custom response instead of the default 500:

src/server/api.ts
import { server } from './server';
import { router } from './router';

export const api = server.api({
    router,
    onError: (error) => {
        if (error instanceof AuthError) {
            return {
                kind: 'raw-response',
                response: new Response(
                    JSON.stringify({
                        message: 'Unauthorized',
                    }),
                    {
                        status: 401,
                        headers: {
                            'Content-Type': 'application/json',
                        },
                    }
                ),
            };
        }
    },
});

Returning undefined lets the default 500 flow run.

requestMiddleware

requestMiddleware applies a flat array of middleware to every matched route, after route matching and before the handler. Each middleware receives (request, route), where route is the matched route's path and method, and may return a Response to short-circuit.

Type-safe request properties

Auth data doesn't need request mutation, because guards return typed context that handlers receive directly. But if non-auth middleware sets custom properties on the request, such as a request id, use module augmentation to extend NextRequest so handlers can access them without casts:

types/next.d.ts
import 'next/server';

declare module 'next/server' {
    interface NextRequest {
        requestId: string;
    }
}

With the augmentation in place, both middleware and handlers are fully typed:

getUser: async ({ params, request }) => {
    console.log(request.requestId);
    // ...
},

Multipart / file uploads

Routes with contentType: 'multipart/form-data' work natively. The adapter calls request.formData() internally, with no additional middleware needed.

uploadAvatar: async ({ body }) => {
    // body.file is a File, body.userId is a string
    await storage.upload(body.userId, body.file);
    return {
        status: 200,
        body: {
            size: body.file.size,
            userId: body.userId,
        },
    };
},

Reference

On this page