Server

The server runtime handles HTTP requests with full type safety, automatic request/response validation, and explicit request lifecycle hooks.

Creating a server

import { createNextServer } from "@contract-kit/next";
import { definePorts } from "@contract-kit/ports";

const ports = definePorts({
  db: {
    todos: {
      findById: async (id: string) => ({ id, title: "Example", completed: false }),
    },
  },
});

export const server = await createNextServer({
  ports,
  createContext: ({ ports, req }) => ({
    // DEMO ONLY: this reads an unauthenticated header to simulate identity.
    // Real applications should verify a signed token or session cookie first.
    requestId: crypto.randomUUID(),
    userId: req.headers.get("x-user-id"),
    ports,
  }),
  onError: ({ err }) => ({
    status: 500,
    body: {
      code: "INTERNAL_SERVER_ERROR",
      message: "Internal server error",
      ...(err instanceof Error
        ? { details: { message: err.message } }
        : {}),
    },
  }),
});

Ports

Ports define your application's external dependencies — databases, caches, mailers, etc. Using definePorts gives you full type inference throughout your handlers.

import { definePorts } from "@contract-kit/ports";

export const ports = definePorts({
  db: { /* your database adapter */ },
  cache: { /* your cache adapter */ },
  mail: { /* your mail adapter */ },
});

See Providers for ready-made implementations.

Context

The createContext function runs on every request. It receives ports and the raw request, and returns a context object available to all handlers.

createContext: ({ ports, req }) => ({
  // DEMO ONLY: this reads an unauthenticated header to simulate identity.
  // Real applications should verify a signed token or session cookie first.
  requestId: crypto.randomUUID(),
  userId: req.headers.get("x-user-id") || null,
  ports,
}),

Route handlers

Use server.route(contract).handle() to implement an endpoint. The handler receives validated, typed parameters.

import { server } from "@/app/server";
import { getTodo } from "@/app/contracts/todo";

export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
  const todo = await ctx.ports.db.todos.findById(path.id);

  if (!todo) {
    return {
      status: 404,
      body: { code: "TODO_NOT_FOUND", message: "Todo not found" },
    };
  }

  return { status: 200, body: todo };
});

The handler object gives you:

All values are fully typed based on the contract definition.

export const POST = server.route(createTodo).handle(async ({ ctx, body }) => {
  const todo = await ctx.ports.db.todos.create({
    title: body.title,
    completed: body.completed ?? false,
  });

  return { status: 201, body: todo };
});

Raw requests and non-JSON responses

Contract Kit is JSON-first: returning a plain object from a handler produces a JSON response and declared response schemas validate that JSON value.

For transport-level cases such as webhook signatures, downloads, plain text, redirects, or streams, use the raw request readers and return a native web Response.

export const POST = server.route(stripeWebhook).handle(async ({ req }) => {
  const rawBody = await req.text();
  const signature = req.headers.get("stripe-signature");

  verifyWebhookSignature(rawBody, signature);

  return { status: 200, body: { received: true } };
});

export const GET = server.route(downloadReport).handle(async () =>
  new Response(await loadReportBytes(), {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": 'attachment; filename="report.pdf"',
    },
  }),
);

export const GET = server.route(robotsTxt).handle(async () =>
  new Response("User-agent: *\nAllow: /\n", {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  }),
);

export const POST = server.route(startCheckout).handle(async () =>
  Response.redirect("https://checkout.example.com/session/123", 303),
);

Native Response instances are outside JSON response validation. Use { status, body } when you want the response contract enforced; use Response when the route owns transport details directly. Response-shaping hooks such as beforeSend only run for plain Contract Kit responses; observation hooks such as afterSend still receive the final status and headers.

Route matching

Routes are matched by HTTP method and path. Static segments are more specific than dynamic segments, so /posts/new wins over /posts/:slug regardless of registration order.

Dynamic parameter names do not affect matching. Registering both /items/:id and /items/:slug for the same method throws an ambiguity error because both routes match the same URLs.

Hooks

Request lifecycle hooks

Add hooks that can short-circuit before parsing, prepare context-aware handler state, shape the final response, or observe completion. See Hooks for lifecycle order, metadata-driven hooks, and response ownership details.

import {
  createLoggingHooks,
} from "@contract-kit/server/hooks";

const logging = createLoggingHooks({
  logger: console,
  requestIdHeader: "x-request-id",
});

const authHooks = {
  name: "auth",
  beforeHandle: async ({ req, ctx, contract }) => {
    if (contract.metadata?.auth !== "required") {
      return;
    }

    const token = req.headers.get("authorization");
    if (!token) {
      return {
        ctx,
        response: {
          status: 401,
          body: { code: "UNAUTHORIZED", message: "Unauthorized" },
        },
      };
    }
  },
};

export const server = await createNextServer({
  ports,
  hooks: [logging, authHooks],
  createContext: ({ ports }) => ({ ports }),
});

This works because contracts can declare metadata like .meta({ auth: "required" }). Early onRequest hooks see the raw request, matched params, available ports, and route metadata before parsing or createContext. beforeHandle hooks run after validation and context creation, so they can enrich ctx, enforce user-scoped policies, or short-circuit before the handler.

Error handling

Automatic validation

Contract Kit automatically validates incoming requests against your contract schemas. If validation fails, it returns a 422 response with structured error details. Your handler only runs if the request is valid.

It also validates outgoing handler responses against contract.responses. If a handler returns an undeclared status or a body that does not match the declared schema, Contract Kit returns a 500 response with a contract-violation error instead of silently drifting from the contract.

When validation fails, the response body contains structured error details:

{
  "code": "VALIDATION_ERROR",
  "message": "Invalid request body",
  "details": {
    "issues": [
      { "path": ["title"], "message": "Required" },
      { "path": ["completed"], "message": "Expected boolean, received string" }
    ]
  }
}

Response ownership

Contract Kit treats responses as route-owned, framework-owned, or transport-owned.

Route-owned responses are validated against contract.responses when the route declares response schemas:

If either returns an undeclared status or a body that does not match the declared schema, Contract Kit returns a 500 contract-violation response. If contract.responses is empty, no response validation is applied.

Framework-owned responses skip route response validation and use Contract Kit's standard error envelope when applicable:

Transport-owned responses are native Response objects returned from handlers or hooks. They bypass JSON response validation and beforeSend, but afterSend still observes their status and headers. Use them for non-JSON payloads, redirects, streaming, and Server-Sent Events.

This keeps the contract strict for business responses while letting infrastructure concerns such as auth, CORS, rate limits, malformed requests, and unexpected failures stay outside each route's response union.

Custom errors

Return error responses that match your contract's error schemas. Route-owned errors may use any declared schema; { code, message, details? } keeps them consistent with framework-owned errors.

export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
  const todo = await ctx.ports.db.todos.findById(path.id);

  if (!todo) {
    return {
      status: 404,
      body: { code: "TODO_NOT_FOUND", message: "Todo not found" },
    };
  }

  return { status: 200, body: todo };
});

Error chaining with cause

Use AppError to throw structured HTTP errors. The cause option preserves the original error for debugging.

import { AppError, httpErrors } from "@contract-kit/errors";

try {
  await db.query(...);
} catch (dbError) {
  throw new AppError(
    httpErrors.InternalServerError,
    { table: "todos" },
    "Database query failed",
    { cause: dbError },
  );
}

The cause is available via error.cause and will appear in server logs but is never exposed to the client.

Global error handler

The onError callback maps unknown or otherwise unhandled exceptions to HTTP responses.

onError: ({ err, req, ctx }) => {
  console.error("Unhandled error:", err);
  return {
    status: 500,
    body: {
      code: "INTERNAL_SERVER_ERROR",
      message: "Internal server error",
    },
  };
},

Next.js integration

The @contract-kit/next adapter works with the Next.js App Router. Route files export the handler directly.

// app/api/todos/[id]/route.ts
import { server } from "@/app/server";
import { getTodo, updateTodo, deleteTodo } from "@/app/contracts/todo";

export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
  const todo = await ctx.ports.db.todos.findById(path.id);
  return { status: 200, body: todo };
});

export const PATCH = server.route(updateTodo).handle(async ({ ctx, path, body }) => {
  const todo = await ctx.ports.db.todos.update(path.id, body);
  return { status: 200, body: todo };
});

export const DELETE = server.route(deleteTodo).handle(async ({ ctx, path }) => {
  await ctx.ports.db.todos.delete(path.id);
  return { status: 204 };
});

The Next adapter also includes helpers for common app glue:

// app/api/openapi/route.ts
import { createOpenAPIHandler } from "@contract-kit/next";
import { allContracts } from "@/app/contracts";

export const GET = createOpenAPIHandler(allContracts, {
  title: "My API",
  version: "1.0.0",
});

// app/lib/api-client.ts
import { createNextClient } from "@contract-kit/next";

export const client = createNextClient();