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:
onRequestruns after route matching and before request parsing orcreateContext.- Request path, query, and body are parsed and validated.
createContextruns.beforeHandleruns before the route handler.- The route handler runs.
beforeSendcan shape the final response.afterSendobserves completion.onErrormaps 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.