Server

The server runtime handles HTTP requests with full type safety, automatic validation, and middleware support.

Creating a server

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

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

export const server = await createNextServer({
  ports,
  createContext: ({ ports, req }) => ({
    requestId: crypto.randomUUID(),
    userId: req.headers.get("x-user-id"),
    ports,
  }),
  onUnhandledError: (error) => ({
    status: 500,
    body: { message: "Internal server error" },
  }),
});

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";

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 }) => ({
  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: { 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 };
});

Middleware

Global middleware

Add middleware that runs on every request.

export const server = await createNextServer({
  ports,
  middleware: [
    async ({ req, ctx, meta, next }) => {
      const start = Date.now();
      const response = await next(ctx);
      console.log(`${req.method} ${req.url} - ${Date.now() - start}ms`);
      return response;
    },
  ],
  createContext: ({ ports }) => ({ ports }),
});

Metadata-aware middleware

Use contract metadata to conditionally apply behavior.

const authMiddleware = async ({ req, meta, ctx, next }) => {
  if (meta?.auth !== "required") {
    return next(ctx);
  }

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

  return next(ctx);
};

This works because contracts can declare metadata like .meta({ auth: "required" }). The middleware checks it and decides whether to enforce the behavior.

Error handling

Automatic validation

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

Custom errors

Return error responses that match your contract's error schemas.

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: { message: "Todo not found" } };
  }

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

Global error handler

The onUnhandledError callback catches any unhandled exceptions.

onUnhandledError: (error, { req, ctx }) => {
  console.error("Unhandled error:", error);
  return {
    status: 500,
    body: { 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 };
});