Bisher haben wir Routen und APIs gebaut — jeweils einzeln. Sobald deine App bestimmte Logik bei jedem Request ausführen soll (Auth-Prüfung, Logging, Tracing, eigene Header), wäre es lästig, sie in jeden Handler zu kopieren. Genau dafür gibt es Hooks: globale Funktionen, die SvelteKit zwischen den Request und deine Routen schaltet. Die wichtigste heißt handle — sie ist Sveltes Variante von Express-Middleware. Daneben gibt es handleFetch und handleError. Dieser Artikel zeigt, wofür man sie braucht und wie sie zusammenspielen.
Wo leben Hooks?
Hooks gehören in spezielle Dateien im src/-Verzeichnis:
src/
├── hooks.server.ts Server-Hooks (handle, handleFetch, handleError)
├── hooks.client.ts Client-Hooks (handleError nur)
└── hooks.ts Universelle Hooks (selten gebraucht)Im Alltag arbeitest du mit hooks.server.ts — dort lebt der handle-Hook, der die meisten App-weiten Aufgaben erledigt.
handle — der Standard-Hook
Der handle-Hook bekommt jeden eingehenden Server-Request als Erstes. Er entscheidet, was als nächstes passiert — meist: ergänze Daten, lass die Route normal laufen, ergänze danach noch etwas an der Antwort.
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// Vor dem Routen-Handler:
const start = Date.now();
// Routen-Handler ausführen
const response = await resolve(event);
// Nach dem Routen-Handler:
const duration = Date.now() - start;
console.log(`${event.request.method} ${event.url.pathname} — ${duration}ms`);
return response;
};Die zwei Argumente event und resolve sind das Schema:
eventist der Request samt allem, was du brauchst (URL, Cookies, Headers, Method, …).resolve(event)sagt SvelteKit: „Jetzt führ die normale Route aus.” Du bekommst dieResponsezurück und kannst sie noch verändern.
Was du vor resolve machst, läuft bevor die Route greift. Was du nach resolve machst, hat die fertige Antwort vor sich.
Klassischer Use Case: Auth
Der häufigste Einsatz von handle ist Authentifizierung. Der Hook liest den Session-Cookie, holt den User aus der Datenbank und legt ihn an event.locals — damit alle Routen Zugriff haben, ohne den Cookie selbst anzufassen.
import type { Handle } from '@sveltejs/kit';
import { getUserBySession } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get('session');
event.locals.user = sessionId ? await getUserBySession(sessionId) : null;
return resolve(event);
};Damit ist event.locals.user in jedem Server-Load, in jedem +server.ts-Handler und in jeder Form Action verfügbar — ohne dass du den Cookie nochmal liest.
Damit TypeScript Bescheid weiß, definierst du Locals in app.d.ts:
declare global {
namespace App {
interface Locals {
user: { id: string; email: string } | null;
}
}
}
export {};Die resolve-Funktion hat Optionen
resolve(event) hat ein zweites Argument für Anpassungen während des Renderings:
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
return html.replace('<html', '<html data-theme="dark"');
},
filterSerializedResponseHeaders: (name) => name.startsWith('x-'),
preload: ({ type, path }) => type === 'js' || type === 'css',
});
};Die wichtigsten Optionen:
transformPageChunk— du bekommst HTML-Stücke und kannst sie verändern. Klassisch: Theme-Klasse setzen, Sprach-Attribut, Inline-Skripte einfügen.filterSerializedResponseHeaders— kontrolliert, welche Header beim SSR-Daten-Sharing weitergereicht werden. Selten relevant.preload— entscheidet, welche Asset-Typen mit<link rel="modulepreload">geprefetched werden.
Für die meisten Apps reicht resolve(event) ohne Optionen. Die Konfiguration kommt erst, wenn man Rendering-Details fein justieren will.
Mehrere Hooks stapeln mit sequence
Was, wenn du mehrere Hooks hast — Auth, Logging, CSP-Header, Locale-Erkennung? SvelteKit lässt dich nur einen handle-Export pro Datei machen. Damit du nicht alles in eine Mega-Funktion zwängen musst, gibt es den sequence-Helper.
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
const auth: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get('session');
event.locals.user = sessionId ? await getUserBySession(sessionId) : null;
return resolve(event);
};
const logging: Handle = async ({ event, resolve }) => {
const start = Date.now();
const response = await resolve(event);
console.log(`${event.url.pathname} — ${Date.now() - start}ms`);
return response;
};
const securityHeaders: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
return response;
};
export const handle = sequence(auth, logging, securityHeaders);sequence(a, b, c) führt die Hooks in dieser Reihenfolge aus. Wichtig zu verstehen: Der Stack ist verschachtelt — a läuft zuerst, ruft resolve auf (was b aufruft), das wieder resolve ruft (was c aufruft). Code vor resolve läuft also a → b → c, Code nach resolve läuft c → b → a.
Das ist genau das Verhalten von Express-Middleware. Wer aus diesem Ökosystem kommt, fühlt sich sofort zuhause.
handleFetch — interne fetch-Aufrufe abfangen
Wenn deine load-Funktion event.fetch(url) macht, fängt SvelteKit den Aufruf ab. Mit handleFetch kannst du in diesen Aufruf eingreifen — etwa um an Server-Requests einen Auth-Header anzuhängen oder die URL umzuschreiben.
import type { HandleFetch } from '@sveltejs/kit';
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
// Wenn der Aufruf an unsere eigene API geht, Auth-Token mitsenden
if (request.url.startsWith('https://api.example.com/')) {
request.headers.set('Authorization', `Bearer ${API_TOKEN}`);
}
return fetch(request);
};Klassischer Anwendungsfall: Im Server-Render rufst du eine externe API mit Auth-Token. Den Token willst du nicht in den Browser leaken (also nicht im +page.ts-Code), aber du willst denselben URL-Pfad in beiden Welten benutzen. handleFetch ist der saubere Ort dafür — der Token wird nur server-seitig drangehängt.
handleError — globales Error-Logging
Wenn in einer Route etwas Unerwartetes schiefgeht (uncaught Exception, nicht ein bewusstes error(...)), zeigt SvelteKit eine generische Fehlerseite. Mit handleError kannst du den Fehler abfangen und in einen Logger oder Error-Tracker schicken.
import type { HandleServerError } from '@sveltejs/kit';
import * as Sentry from '@sentry/sveltekit';
export const handleError: HandleServerError = ({ error, event }) => {
Sentry.captureException(error, {
tags: {
url: event.url.pathname,
method: event.request.method,
},
});
return {
message: 'Etwas ist schiefgelaufen.',
};
};Was zurückgegeben wird, landet als error-Objekt in der Page-Komponente (page.error.message). Wer eigene Felder zurückgeben will, deklariert sie in app.d.ts unter App.Error.
handleError gibt es auch im Client-Hook (hooks.client.ts) — für Fehler, die im Browser nach der Hydration auftreten.
Häufige Stolperfallen
handle ohne resolve(event) aufgerufen.
Wenn du resolve nicht aufrufst, antwortet SvelteKit gar nicht — der Request bleibt hängen, der Browser zeigt eine leere Seite. Jeder Hook muss resolve aufrufen (oder selbst eine Response zurückgeben).
event.locals außerhalb von handle setzen.
locals ist eine Per-Request-Eigenschaft. Du setzt sie in handle und liest sie in Loads/Actions — nicht umgekehrt. Wer in einer Route locals.foo = 'bar' setzt, ändert nur das aktuelle event-Objekt, ohne globalen Effekt.
Mehrere handle-Exports in einer Datei.
Geht nicht. SvelteKit erkennt nur einen handle-Export pro hooks.server.ts. Wer mehrere Hooks hat, muss sie mit sequence(...) kombinieren.
Reihenfolge in sequence falsch.
Die Reihenfolge der Argumente bestimmt, in welcher Reihenfolge Code vor resolve läuft. Wer Logging hinter Auth setzt, kann den User schon im Log haben. Wer es davor setzt, sieht ihn nicht. Reihenfolge bewusst wählen.
handleError für bewusste Fehler genutzt.
error(404, ...) und redirect(...) sind bewusste Fehler — sie laufen nicht durch handleError. Der Hook fängt nur unerwartete Exceptions. Logging in handleError ist also Pre-Filter-mäßig.
TypeScript zeigt unknown für event.locals.user.
Du hast Locals nicht in app.d.ts deklariert. Sobald du dort interface Locals { user: ... } ergänzt, erkennt TypeScript den Typ überall in der App.
handleFetch löst Endlos-Loops aus.
Wenn du in handleFetch einen Aufruf modifizierst und fetch(request) nicht aufrufst, sondern event.fetch(url), kann das in eine Schleife gehen. Innerhalb des Hooks immer das übergebene fetch benutzen, nicht das auf dem event.
Cookies in Hooks setzen vor resolve.
Cookies, die du vor resolve(event) setzt, sind nicht automatisch im Response — resolve baut den Response erst danach. Wenn du Cookies setzen willst, mach das nach resolve mit event.cookies.set(...) — die werden dem Response korrekt mitgegeben.