Hooks

Hooks are ordered lifecycle functions for infrastructure behavior around route handlers. Use them for auth, CORS, logging, tracing, rate limits, response shaping, and error mapping.

Hooks are configured on the server:

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

Lifecycle

Hooks run in this order:

  1. onRequest runs after route matching and before request parsing or createContext.
  2. Request path, query, and body are parsed and validated.
  3. createContext runs.
  4. beforeHandle runs before the route handler.
  5. The route handler runs.
  6. beforeSend can shape the final response.
  7. afterSend observes completion.
  8. onError maps hook or handler failures.

onRequest

Use onRequest for raw request concerns that do not need parsed input or context.

const corsHooks = {
  name: "cors",
  onRequest: ({ req }) => {
    if (req.method === "OPTIONS") {
      return {
        status: 204,
        headers: {
          "access-control-allow-origin": "*",
          "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS",
        },
      };
    }
  },
};

beforeHandle

Use beforeHandle when you need validated input, context, ports, or route metadata.

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

    const user = await ctx.ports.auth.getUser(req);
    if (!user) {
      return {
        ctx,
        response: {
          status: 401,
          body: { code: "UNAUTHORIZED", message: "Unauthorized" },
        },
      };
    }

    return {
      ctx: { ...ctx, user },
    };
  },
};

beforeHandle can return a new ctx, a short-circuit response, or both.

Metadata-driven hooks

Contracts can carry metadata for hooks.

export const createTodo = todos
  .post("/api/todos")
  .meta({
    auth: "required",
    rateLimit: { max: 10, windowSec: 60 },
  })
  .body(CreateTodoSchema)
  .responses({ 201: TodoSchema });
const rateLimitHooks = {
  name: "rate-limit",
  beforeHandle: async ({ ctx, contract }) => {
    const rule = contract.metadata?.rateLimit;
    if (!rule) return;

    const result = await ctx.ports.rateLimit.hit({
      key: `route:${contract.name}`,
      limit: rule.max,
      windowSec: rule.windowSec,
    });

    if (result.limited) {
      return {
        ctx,
        response: {
          status: 429,
          body: { code: "RATE_LIMITED", message: "Too many requests" },
        },
      };
    }
  },
};

beforeSend and afterSend

Use beforeSend to add headers or shape framework-owned responses. Use afterSend for logging, metrics, and tracing.

const loggingHooks = {
  name: "logging",
  beforeSend: ({ response }) => ({
    ...response,
    headers: {
      ...response.headers,
      "x-contract-kit": "1",
    },
  }),
  afterSend: ({ req, response, durationMs }) => {
    console.info(req.method, response.status, durationMs);
  },
};

Error handling

Hook-thrown errors and handler-thrown unknown errors are passed to onError.

const errorHooks = {
  name: "errors",
  onError: ({ err }) => {
    console.error(err);
    return {
      status: 500,
      body: {
        code: "INTERNAL_SERVER_ERROR",
        message: "Internal server error",
      },
    };
  },
};

Hook short-circuit responses and hook/server onError responses are framework-owned, so they skip route response validation.