Adapters

Next.js

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

@kizunajs/next serves a Kizuna API from a Next.js App Router application, through a single catch-all route file.

Requires Next.js ≥ 16 (App Router).

pnpm add @kizunajs/next@beta
bun add @kizunajs/next@beta
npm install @kizunajs/next@beta

The adapter

nextAdapter() is what you name in kizuna.config.ts. Naming it is what gives every handler on this API Next's own NextRequest, typed.

Declare a route

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

export const users = k.routes('users', {
    listUsers: k
        .route({
            method: 'GET',
            path: '/users',
            query: ListUsersQuerySchema,
            responses: {
                200: UserListSchema,
            },
        })
        .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(),
            },
        })),
    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: 'Not found',
                    },
                };
            }
            return {
                status: 200,
                body: user,
            };
        }),
});

Name Next in your config

kizuna.config.ts
import { defineConfig } from 'kizunajs';
import { nextAdapter } from '@kizunajs/next';
import { users } from './src/routes/users';

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

Mount it at a catch-all route

mount hands back a handler per HTTP method, which Next dispatches for you:

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',
});

Handler context

Each handler receives { params, query, body, headers, request }. The request is the native NextRequest, useful when you need to read cookies or anything else Next exposes.

src/routes/users.ts
.handler(async ({ params, request }) => {
    const theme = request.cookies.get('theme')?.value;
    return {
        status: 200,
        body: await db.users.findById(params.id),
    };
});

Guards

A guard sits on the identity it authenticates, and receives the credential extracted and typed, alongside the native request. See the Authentication guide.

src/identities.ts
import { k } from './k';

export const user = k.identity
    .bearer({
        context: z.object({
            userId: z.string(),
        }),
    })
    .guard(async ({ bearer, deny }) => {
        const session = bearer ? await verifySession(bearer.token) : undefined;
        if (!session) {
            return deny({
                status: 401,
                body: {
                    detail: 'Unauthorized',
                },
            });
        }
        return {
            userId: session.userId,
        };
    });

Middleware

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

Options

src/app/api/[...kizuna]/route.ts
export const { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS } = kizuna.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.

onError

Pass onError to nextAdapter(), or to mount, to send your own response:

kizuna.config.ts
export default defineConfig({
    adapter: nextAdapter({
        onError: (error) => {
            if (error instanceof AuthError) {
                return {
                    kind: 'raw-response',
                    response: new Response(
                        JSON.stringify({
                            message: 'Unauthorized',
                        }),
                        {
                            status: 401,
                            headers: {
                                'Content-Type': 'application/json',
                            },
                        }
                    ),
                };
            }
        },
    }),
    routes,
});

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

Middleware that sets custom properties on the request, such as a request id, needs module augmentation to extend NextRequest so handlers reach them without casts. Authentication data does not: guards hand handlers typed context directly.

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

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

Both middleware and handlers are then typed:

src/routes/users.ts
.handler(async ({ params, request }) => {
    console.log(request.requestId);
    // ...
});

Multipart / file uploads

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

src/routes/users.ts
.handler(async ({ body }) => {
    await storage.upload(body.userId, body.file);
    return {
        status: 200,
        body: {
            size: body.file.size,
            userId: body.userId,
        },
    };
});

Streams

A stream response is returned as a NextResponse with a ReadableStream body. The generator's signal is request.signal.

Reference

On this page