hooks.server.ts
hooks.server.ts sits in the project root and wraps every request. It is where session lookup, redirects and response headers belong — anything that must happen before a route's load runs.
Grimoire loads it automatically; there is nothing to register.
It has two lifecycle hooks, and they run at different times:
| Hook | Runs | For |
|---|---|---|
init | Once at server start, before any request | Pools, migrations, config checks |
handle | Every request | Auth, headers, logging, redirects |
init and handle covers which is which and when each runs per worker.
The event
handle receives one object:
| Field | Type | What it is |
|---|---|---|
event.request | Request | The incoming request. |
event.url | URL | Parsed URL. |
event.params | Record<string, string> | Dynamic segments, already matched. |
event.locals | App.Locals | Mutable. This is how you pass data to load. |
event.cookies | Cookies | get, set, delete for the response. |
event.route | { id: string } | Which route matched. |
event.fetch | typeof fetch | Request-scoped fetch. |
event.setHeaders | (h) => void | Add headers to the response. |
A single handler
You must call resolve and return its response. Everything before it runs first; everything after it gets the finished response:
import type { Handle } from "@sigil-dev/grimoire/hooks";
export const handle: Handle = async ({ event, resolve }) => {
// before the route
const session = event.cookies.get("session");
event.locals.user = session ? await getUser(session) : null;
const response = await resolve(event);
// after the route
response.headers.set("x-served-by", "grimoire");
return response;
};Returning something else instead of the response breaks routing — the route's own response never reaches the client.
locals
locals is the only channel between the hook and a route. Whatever you put there is what load reads:
export function load({ locals }: LoadContext) {
return { user: locals.user };
}Declare its shape once in src/app.d.ts so it is typed everywhere:
declare global {
namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}
}Without that declaration locals.user is unknown and you lose type safety on the most security-relevant value in your app.
Redirecting and short-circuiting
Return a response without calling resolve to answer the request yourself:
export const handle: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
if (pathname.startsWith("/admin") && !event.locals.user) {
return new Response(null, {
status: 303,
headers: { Location: "/login" },
});
}
return resolve(event);
};For a plain redirect throw redirect("/login") is shorter and gives the same 303.
Cookies
event.cookies.set("session", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30,
});
event.cookies.delete("old_session");path defaults to /, which is almost always what you want. See form actions for the full option list.
Errors
handleError sees anything that would otherwise be a 500:
import type { HandleError } from "@sigil-dev/grimoire/hooks";
export const handleError: HandleError = ({ error, status, message, event }) => {
console.error(`[${status}] ${event.url.pathname}: ${message}`, error);
// report to Sentry, write to a log file, etc.
};It is for reporting, not rendering — the nearest +error.tsx still decides what the user sees. Returning from it changes nothing.
Running once at startup
init runs once when the server boots, before any request:
export const init = async () => {
await connectToDatabase();
if (!env.SESSION_SECRET) throw new Error("SESSION_SECRET is not set");
};Use it to verify and warm up what module scope created, not to do per-request work. Note that it runs per worker process, so with sigil start --scale api=3 you get three runs and three pools. init and handle has the details and the migration locking pattern.
After the response
afterResponse defers work until the response has been sent, so a slow side effect stops being something the user waits for:
import { afterResponse } from "@sigil-dev/grimoire/server";
export const load = async () => {
const user = await getUser();
afterResponse(async () => {
await touchLastSeen(user.id);
});
return { user };
};It fires when the response body finishes streaming, not when the handler returns — so on a streaming page the work does not start while the client is still downloading.
This is not a queue. A task that has not finished is lost if the process exits, so nothing here survives a deploy or a crash. That makes it right for work that is cheap and re-derivable — analytics, cache warming, a last-seen timestamp, regenerating a thumbnail. It is wrong for anything a user was promised, like a password reset email or a charge: those need a durable queue, because the guarantee you are buying is that the work still happens if the process dies.
Errors are logged, never thrown. The response is already sent by the time a task runs, so there is nobody left to report a failure to. On SIGINT and SIGTERM the server waits for in-flight tasks, bounded at five seconds, so a task already started is not killed with the process.
Chaining
sequence() runs handlers in order. Each one's resolve is the next one, so "after" code unwinds in reverse — the first handler's post-resolve code runs last:
import { sequence, type Handle } from "@sigil-dev/grimoire/hooks";
import { logger } from "./hooks/logger";
import { auth } from "./hooks/auth";
export const handle = sequence(logger, auth);Next
- Form actions — the POST side
- Sessions and auth — a full cookie session