Why Hooks Exist
The previous articles in this series described what happens inside a load function: receive an event object, make fetch calls, read cookies, and return data. What those articles didn’t cover was the question of what happens before a load function runs. Who validates the session? Who decides whether a user is authenticated before a single load function even starts? Where does event.locals.user come from when your load functions read it?
The answer is hooks.server.ts. This file sits at the root of your src/ directory and exports functions that SvelteKit calls at specific points in the request and response lifecycle. Unlike load functions, (which are scoped to a single route), hooks run for every request that passes through the server, regardless of which route it targets.
This article covers the full hooks system: the handle hook that wraps every request, how to use it to populate event.locals with validated session data, the correct TypeScript setup for App.Locals, the handleFetch hook for rewriting server-side fetch calls, the handleError hook for capturing and logging exceptions, and the sequence helper for composing multiple hook functions cleanly.
The Problem with Session Validation in Load Functions
Consider a SvelteKit application where all different routes need to know who the current user is. With only load functions available, there are two bad options.
The first bad option is to read and validate the session cookie in every individual load function. When we have twenty routes it means twenty places where the validation logic lives, twenty places where a bug could make a route unsecured, and twenty redundant cookie reads and session API calls on every request.
The second bad option is to read the cookie in the root layout server load function and trust that await parent() in every page load function will give each page access to the session. This feels clean, but it has a critical flaw: SvelteKit does not re-run a layout load on every client-side navigation. It only re-runs it when that load’s data dependencies are explicitly invalidated — via invalidate() or invalidateAll().
Without that, the cached result from the initial server render is reused, meaning the session check is silently skipped for subsequent page visits. More importantly, form actions bypass layout loads entirely. A form action in a route cannot use parent() to get the session because form actions are not load functions and parent() is not available to them.
Solution: hooks.server.ts and event.locals
The solution is a hooks.server.ts file that can export any of the following hooks:
handle: runs for every request — the right place to validate the session and populateevent.localshandleFetch: runs for every server-side fetch call, useful for rewriting URLs and injecting auth headershandleError: runs for unexpected exceptions, the right place to report to monitoring services without exposing details to the clientsequence: a utility imported from@sveltejs/kit/hooksto compose multipleHandlefunctions into a singlehandleexport, running them in order
event.locals is a plain JavaScript object that SvelteKit creates fresh for every incoming request. The handle hook writes to it before calling resolve, and every server-side handler for that same request — load functions, form actions, API handlers — reads from it. It is never serialised to the browser and is discarded once the response is delivered. The rest of this article explains its full lifecycle.
The handle Hook
The handle hook is the most important export from hooks.server.ts. It runs for every request without exception: page loads, API routes, form actions, prefetch requests, and any other HTTP traffic that hits your SvelteKit server. It runs once per request, populates event.locals with the validated session, and every subsequent handler for that request reads from locals rather than re-doing the validation. It receives the request event and a resolve function that continues the request pipeline.
The event object and event.locals
Every hook and load function in SvelteKit receives an event object. It is the same object type throughout the server-side lifecycle: hooks receive it, load functions receive it, form actions receive it. The properties most relevant to hooks are:
event.request— the standard WebRequestobject with headers, method, and bodyevent.cookies— a typed wrapper for reading and writing cookies on the responseevent.url— the parsedURLof the incoming requestevent.locals— a plain JavaScript object, server-only, created fresh for each requestevent.getClientAddress()— returns the connecting client’s IP address
event.locals deserves particular attention. It starts as an empty object {} at the beginning of every request and exists purely in server memory — nothing from it is ever sent to the browser. The handle hook populates it before the route handler runs; every server load function, form action, and API handler for that same request then reads from it. Once the response is sent the object is discarded.
Think of it as a typed, request-scoped scratchpad: the hook fills it in, and every handler downstream consumes it — without any of them needing to re-fetch or re-validate the same data.
The passthrough and the middleware shape
The simplest possible implementation just calls resolve directly:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
// any code you add here runs before the route is processed
return resolve(event)
} This is a passthrough; it does nothing. But the structure reveals the pattern. Everything before resolve(event) runs before the route is processed; everything after runs after the response is generated.
resolve(event) is not just the next function in the call stack — it represents the entire downstream pipeline: SvelteKit routing, the matched route handler, all load functions and form actions, and ultimately the construction of the response. Anything written to event.locals before it runs is available throughout that pipeline.
This is the canonical middleware shape, and it is where session validation belongs:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null
const sessionToken = event.cookies.get('session')
if (sessionToken) {
// event.fetch in hooks behaves like SvelteKit's enhanced fetch:
// it handles relative URLs correctly and forwards cookies automatically.
const res = await event.fetch('/api/session/validate', {
headers: { Authorization: `Bearer ${sessionToken}` }
})
if (res.ok) {
const user = await res.json()
event.locals.user = user
}
}
return resolve(event)
} After this hook runs, every server load function, every form action, and every +server.ts handler in the entire application can read event.locals.user to get the validated user without making another API call. The validation happens once per HTTP request, at the front of the pipeline.
event.fetch is available in the handle hook and behaves the same way as the fetch provided to load functions: it resolves relative URLs against the current request’s origin and forwards cookies automatically. Always use event.fetch for API calls within the hook rather than the global fetch.
An alternative and often preferable approach is to call your session validation logic directly rather than through HTTP, by importing your auth library or database query functions inline.
// src/hooks.server.ts — calling auth logic directly (preferred in most apps)
import type { Handle } from '@sveltejs/kit'
import { validateSessionToken } from '$lib/server/auth'
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null
const sessionToken = event.cookies.get('session')
if (sessionToken) {
event.locals.user = await validateSessionToken(sessionToken)
}
return resolve(event)
} Direct imports from $lib/server/ are available in hooks, and they are the right choice when your auth logic is already encapsulated in a server module. The HTTP approach is useful when you are calling an external auth provider rather than your own session store.
Typing locals with App.Locals
The hook populates event.locals and every server-side handler downstream reads from it — but those two sides of the contract exist in completely separate files. The hook writes locals.user; a load function in a completely different route reads locals.user. Without a type declaration connecting them, TypeScript has no way to verify that both sides agree on what locals.user actually is. The load function is effectively reading from an untyped object, and any typo or shape mismatch becomes a runtime error rather than a compile-time one.
SvelteKit solves this with a single ambient declaration in src/app.d.ts. Declare the shape of App.Locals once, and TypeScript enforces it everywhere: in the hook when you write to event.locals, every load function, form action, and API handler when you read from it. The declaration is the contract, the hook and the handlers are the two parties bound by it.
The src/app.d.ts declaration file
As already mentioned, without any type declaration, event.locals is typed as an empty object {}. Reading event.locals.user immediately gives you a TypeScript error: Property 'user' does not exist on type 'Locals'. You would have to cast it with as any everywhere, losing all type safety.
SvelteKit solves this with a TypeScript feature called module augmentation. The file src/app.d.ts (generated when you scaffold a SvelteKit project) is a special ambient declaration file that lets you extend interfaces from the @sveltejs/kit package without modifying it. The declare global { namespace App { ... } } block reaches into the global App namespace that SvelteKit defines and merges your properties into it. TypeScript picks this up automatically across the entire codebase, no imports required anywhere.
The export {} at the bottom is required to make the file a module rather than a script, which is what enables the global augmentation to work.
// src/app.d.ts
declare global {
namespace App {
interface Locals {
user: {
id: string
email: string
role: 'user' | 'admin' | 'superadmin'
} | null
}
}
}
export {} As many pages can be accessed without a session, setting user to null as the union type accounts for the unauthenticated case, which is a common pattern for session data. With this declaration in place, event.locals.user is now fully typed as { id: string; email: string; role: 'user' | 'admin' | 'superadmin' } | null everywhere in your server code.
Populating locals in the handle hook
With App.Locals declared, the hook can write to event.locals with full type safety — TypeScript will catch incorrect shapes at compile time. The real benefit becomes visible as your app grows and App.Locals expands to hold multiple pieces of request-scoped data. Extend app.d.ts first, then populate the new fields in the hook:
// src/app.d.ts
declare global {
namespace App {
interface Locals {
user: {
id: string
email: string
role: 'user' | 'admin' | 'superadmin'
tenantId: string | null
} | null
requestId: string
featureFlags: Record<string, boolean>
}
}
}
export {} // src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'
import { validateSessionToken } from '$lib/server/auth'
import { getFeatureFlags } from '$lib/server/flags'
import { randomUUID } from 'crypto'
export const handle: Handle = async ({ event, resolve }) => {
// Assign a request ID for tracing
event.locals.requestId = randomUUID()
// Validate session
const sessionToken = event.cookies.get('session')
event.locals.user = null
if (sessionToken) {
event.locals.user = await validateSessionToken(sessionToken)
}
// Load feature flags (may depend on the user)
event.locals.featureFlags = await getFeatureFlags(event.locals.user?.id ?? null)
return resolve(event)
} Where the type flows
The declaration in app.d.ts is the single source of truth. Once it is written, the type is available automatically in every context where SvelteKit provides an event or locals object — you do not import or re-declare it anywhere.
What belongs in locals
Locals should hold data that is:
- Request-scoped — created fresh per request, not shared across requests
- Needed by multiple handlers — if only one load function uses it, fetch it there instead
- Derived from the request itself — session data, tenant identity, request IDs, feature flags gated on the user, per-user theme preferences read from a cookie, A/B test bucket assignments
Do not put large payloads into locals. Locals are held in memory for the duration of the request. A user record with a few fields is fine; a megabyte of dashboard data or a list of all products is not — that belongs in the load function that needs it.
The full locals propagation chain
The diagram below captures how locals flows through the complete server-side hierarchy for a single request. hooks.server.ts is always the originating authority: it runs first and writes to event.locals.
From there, locals are available to both +layout.server.ts and +page.server.ts directly through the event object. Additionally, +layout.server.ts can pass data down to +page.server.ts through await parent(), meaning a page load can receive both the raw validated locals and any derived or enriched data the layout returned.
Whatever the page and layout server loads return is passed to their corresponding universal load functions and Svelte components via the data prop. Importantly, locals itself is never part of that return value and never reaches the browser.
Two things are worth noting about this picture. First, +page.server.ts has two routes by which it can receive session data: directly from event.locals (set by the hook) and via await parent() (data the layout server load chose to return).
Using await parent() for session data is a common pattern, but the earlier articles in this series established that calling await parent() before starting your own fetches creates a serialisation waterfall.
When the page load only needs the session user already on event.locals, reading locals.user directly avoids the parent call entirely and eliminates the waterfall. await parent() becomes worthwhile when you genuinely need something the layout computed and returned, not just the raw locals value.
Second, the final node — +layout.js | +page.js — represents the boundary where server data crosses into client territory. Whatever the server loads return becomes the data prop in Svelte components. event.locals itself is never in that return value; it stays entirely in server memory.
Reading locals in load functions
Every field populated by the hook is available with its correct type in every server load function in the application:
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ locals, fetch }) => {
if (!locals.user) {
error(401, 'You must be logged in to view the dashboard')
}
const res = await fetch(`/api/users/${locals.user.id}/dashboard`)
const dashboardData = await res.json()
return {
user: locals.user,
dashboard: dashboardData
}
} The locals object available in load functions is the same object populated by the hook. There is no copy or serialisation between them and they share the same reference within a single request.
Locals in API route handlers
Locals are available in +server.ts API handlers when you destructure locals from the handler’s event parameter. The same App.Locals type applies automatically, just as in page load functions:
// src/routes/api/users/+server.ts
import type { RequestHandler } from './$types'
import { error, json } from '@sveltejs/kit'
export const GET: RequestHandler = ({ locals }) => {
if (!locals.user) {
error(401, 'Unauthorised')
}
// locals.user is fully typed here — { id, email, role } | null
return json({ userId: locals.user.id })
} All HTTP verb handlers asGET, POST, PATCH, PUT, DELETE, OPTIONS, and HEAD receive the same event object, so locals is accessible in all of them. This matters for mutation endpoints: a POST handler enforcing permissions reads the same locals.user set by the hook as a GET handler does.
Locals are not available in universal load functions
Important: event.locals is server-onlyThis is a critical point that often causes confusion. The
event.localsobject is only available in server-side code. Universal load functions (+page.tsand+layout.ts) run in both server and client contexts. Thelocalsis a server-only construct and universal loads run in the browser after hydration, where server memory is not accessible.
To pass locals-derived data to a universal load function, the server load must return it explicitly, and the universal load reads it from the data parameter:
// src/routes/+page.server.ts — server load returns the locals value
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = ({ locals }) => {
return {
theme: locals.theme // pass the value out through the return object
}
} // src/routes/+page.ts — universal load receives it via data
import type { PageLoad } from './$types'
export const load: PageLoad = ({ data }) => {
return {
theme: data.theme // data.theme came from +page.server.ts
}
} This two-step pattern is the only way locals values reach universal code. The server load acts as a deliberate gateway: you choose which fields cross the server/client boundary by including them in the return value. Nothing from event.locals leaks across that line automatically.
The handleFetch Hook
What it does and when to use it
The handleFetch hook intercepts all fetch calls made from load functions and +server.ts handlers during server-side rendering. It receives the request object, the fetch function to continue the call, and the event object (which gives it access to event.locals). It can inspect and modify the outgoing request before it executes.
Use handleFetch when you have a cross-cutting concern that applies to all or most outgoing server-side fetch calls — something you would otherwise have to copy into every load function. Common cases are:
- Internal URL rewriting — redirecting public-facing URLs to internal service addresses during SSR to avoid network round-trips
- Shared auth headers — injecting an API key or service-to-service token into requests to a downstream API
- Cookie forwarding — passing the browser’s cookies to an internal API during SSR (not done automatically for cross-origin requests)
Do not use handleFetch for logic that only applies to one or two specific requests — that belongs in those load functions.
One important constraint: handleFetch only runs on the server. After the page hydrates in the browser, fetch calls from client-side code bypass it entirely.
Use case 1: Internal URL rewriting
When your app is deployed behind a load balancer or in a container environment, a fetch to https://your-app.com/api/products from a server load function makes a full round-trip through the network even though the API handler lives in the same process. handleFetch can rewrite that to an internal address:
// src/hooks.server.ts
import type { HandleFetch } from '@sveltejs/kit'
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
// During SSR, rewrite public-facing API calls to the internal service address
if (request.url.startsWith('https://your-app.com/api/')) {
const internalUrl = request.url.replace(
'https://your-app.com/api/',
'http://internal-api-service:3001/'
)
request = new Request(internalUrl, request)
}
return fetch(request)
} Use case 2: Injecting shared auth headers
If all your server load functions call a downstream service that requires an API key, you can add the header centrally rather than in every load function. The event parameter gives you access to event.locals so you can attach user identity alongside the service secret:
// src/hooks.server.ts
import type { HandleFetch } from '@sveltejs/kit'
import { INTERNAL_API_SECRET } from '$env/static/private'
export const handleFetch: HandleFetch = async ({ request, fetch, event }) => {
if (request.url.includes('internal-api-service')) {
request = new Request(request, {
headers: {
...Object.fromEntries(request.headers),
'X-Internal-Secret': INTERNAL_API_SECRET,
'X-User-Id': event.locals.user?.id ?? 'anonymous'
}
})
}
return fetch(request)
} Use case 3: Forwarding cookies to internal APIs
During SSR, when a server load function calls an internal API endpoint (another +server.ts in the same app), SvelteKit automatically forwards the browser’s cookies. But for cross-origin requests, as a different subdomain or port, cookies are not forwarded automatically by the browser’s fetch native SameSite rules, even though the request originates from the server. Use handleFetch to attach the session cookie explicitly:
// src/hooks.server.ts
import type { HandleFetch } from '@sveltejs/kit'
export const handleFetch: HandleFetch = async ({ request, fetch, event }) => {
// Forward session cookie to the internal auth service on a different subdomain
if (request.url.startsWith('https://api.internal.yourapp.com/')) {
const cookie = event.request.headers.get('cookie')
if (cookie) {
request = new Request(request, {
headers: { ...Object.fromEntries(request.headers), cookie }
})
}
}
return fetch(request)
} This pattern is particularly common when your app has a separate auth service or API gateway that needs to see the same session cookie the browser sent.
The handleError Hook
When to use handleError
Use handleError when you need to do any of the following:
- Report to a monitoring service (Sentry, Datadog, etc.) without repeating the reporting code in every load function
- Sanitise the error message shown to the user — by default SvelteKit may expose internal error text
- Attach request context (user ID, request ID, URL) to error reports, which individual load functions cannot do without duplication
- Differentiate error types to give 404s a different user-facing message than 500s
Do not use handleError for expected control flow. If you want to return a 401 when a user is not authenticated, use error(401, 'Unauthorised') inside the load function. handleError will not run for that.
The handleError is only for genuinely unexpected exceptions.
Expected vs unexpected errors
This distinction is the most important concept in handleError. SvelteKit has two kinds of error:
- Expected errors — thrown with
error(status, message)from@sveltejs/kit. These are deliberate control flow. SvelteKit handles them directly and does not callhandleError. - Unexpected errors — any thrown value that is not a SvelteKit error instance:
throw new Error('...'), a failedJSON.parse, a database timeout, an unhandled promise rejection. These are genuine bugs or infrastructure failures. SvelteKit callshandleErrorfor these.
error(404, 'Not found') → handleError NOT called (expected)
throw new Error('DB timeout') → handleError IS called (unexpected)
undefined.property → handleError IS called (runtime error) How SvelteKit calls handleError
handleError receives four arguments:
error— the thrown value (may not be anErrorinstance if someone threw a plain object or string)event— the full request event, giving you access toevent.locals,event.url, andevent.requeststatus— the HTTP status code SvelteKit determined (usually500, but could be404if the route was not found)message— a short default message string SvelteKit derived from the status
The return value of handleError becomes the page.error object that +error.svelte receives. By default without handleError, SvelteKit returns { message } where message is a generic safe string. You can return any shape you want, as long as you declare it.
Typing the error object with App.PageError
Just as App.Locals types event.locals, App.PageError types the object that handleError returns and that +error.svelte receives via page.error. Add it to src/app.d.ts:
// src/app.d.ts
declare global {
namespace App {
interface Locals {
user: { id: string; email: string; role: string } | null
requestId: string
}
interface PageError {
message: string
errorId?: string // a correlatable ID you can show to users
}
}
}
export {} With PageError declared, the return value of handleError is type-checked, and page.error in +error.svelte is fully typed.
Implementing handleError
// src/hooks.server.ts
import type { HandleError } from '@sveltejs/kit'
import { randomUUID } from 'crypto'
export const handleError: HandleError = async ({ error, event, status, message }) => {
const errorId = randomUUID()
// Avoid logging 404s to your error tracker since they are expected
if (status !== 404) {
// In production, replace console.error with your error tracking service
// (e.g. Sentry, Datadog). Here a structured log is used for illustration.
console.error({
errorId,
status,
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
url: event.url.pathname,
userId: event.locals.user?.id ?? null,
requestId: event.locals.requestId
})
}
// The returned object is what page.error will contain in +error.svelte
// Never expose internal error details (stack traces, DB errors) to the client
return {
message: status === 404 ? 'Page not found' : 'An unexpected error occurred',
errorId // show the user a reference ID they can report
}
} Reading the error in +error.svelte
The object returned by handleError is available as page.error in the nearest +error.svelte component. With the PageError type declared, page.error is fully typed:
<!-- src/routes/+error.svelte -->
<script>
import { page } from '$app/state'
</script>
<h1>{page.status}</h1>
<p>{page.error.message}</p>
{#if page.error.errorId}
<p>Reference: <code>{page.error.errorId}</code></p>
{/if} The client-side counterpart: hooks.client.ts
Every error covered above happens on the server, where hooks.server.ts is in control. But once the page hydrates and the user navigates in the browser, errors happen entirely in client-side code. These include component rendering failures, unhandled promise rejections in event handlers, and client-side navigation errors. For those, the server hook is never invoked. SvelteKit provides hooks.client.ts with its own handleError export for exactly this case.
The signature is nearly identical, but the event argument does not include server-only properties like cookies or locals. Use it to send client-side errors to your error tracker:
// src/hooks.client.ts
import type { HandleClientError } from '@sveltejs/kit'
export const handleError: HandleClientError = async ({ error, event, status, message }) => {
if (status !== 404) {
// Use a client-side error reporting SDK here
window.errorTracker?.report({ error, url: event.url.pathname, status })
}
return {
message: status === 404 ? 'Page not found' : 'Something went wrong'
}
} A complete error handling setup has both hooks.server.ts and hooks.client.ts exporting handleError, ensuring that no error (server or client) goes silently untracked.
Composing Multiple Hooks with sequence
The problem with one big handle function
When you only have session validation to do, a single handle function is fine. But real applications accumulate cross-cutting concerns: request logging, rate limiting, security headers, feature flag loading, multi-tenant resolution, CORS handling. If you keep adding all of this to one handle function it becomes an unmaintainable wall of code where every concern is interleaved with every other.
You could try splitting the logic into helper functions called from a single handle, but that is not the same as true composition. Each concern cannot independently decide to short-circuit the request, cannot wrap the response lifecycle independently, and cannot be toggled on or off cleanly in different environments.
sequence solves this by letting each concern live in its own focused Handle function, then wiring them together in one line.
When to use sequence
Use sequence as soon as you have more than one distinct concern in hooks.server.ts. Common candidates for separate hooks are:
- Session validation — always needs to run first so that later hooks and all load functions can read
locals.user - Rate limiting — can short-circuit the request before any auth work happens, saving resources
- Request logging — wraps everything to measure total request duration including all downstream work
- Security response headers — operates on the finished response, so it runs last
- Multi-tenant resolution — reads a subdomain or header to set
locals.tenantIdbefore session validation, since session validation may depend on the tenant - CORS preflight handling — can short-circuit
OPTIONSrequests immediately without touching auth or routing - Feature flag hydration — loads flags after the user is known, so it runs after session validation
Keeping these as separate named functions also makes them individually testable and reusable across different hooks.server.ts files in a monorepo.
How sequence works
sequence calls each hook in order, passing the event and a resolve function that delegates to the next hook in the chain. The result is nested middleware: the outermost hook starts first and finishes last, which is why a logging hook placed first can measure the total request time including all subsequent work.
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks'
import type { Handle } from '@sveltejs/kit'
const logging: Handle = async ({ event, resolve }) => {
const start = performance.now()
const response = await resolve(event)
const duration = Math.round(performance.now() - start)
console.log(`${event.request.method} ${event.url.pathname} ${response.status} ${duration}ms`)
return response
}
const session: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session')
event.locals.user = null
if (token) {
const { validateSessionToken } = await import('$lib/server/auth')
event.locals.user = await validateSessionToken(token)
}
return resolve(event)
}
const security: Handle = async ({ event, resolve }) => {
const response = await resolve(event)
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
return response
}
export const handle = sequence(logging, session, security) Each hook wraps the next like nested middleware. The outermost hook (logging) starts before everything and finishes after everything, which is why it can measure the total request duration. The innermost operation is the actual route handler.
sequence calls each hook in order, passing the event and a resolve function that calls the next hook in the chain. The logging hook sees the full request timing because it awaits resolve(event) which runs all subsequent hooks. The security hook modifies the response headers after the route has been processed.
Order matters
The order you pass hooks to sequence has direct consequences:
- Session before anything that reads
locals.user— hooks later in the chain and all load functions depend onlocals.userbeing populated - Rate limiting before session — if a request is rate-limited you can reject it before doing any database work for auth
- Logging outermost (first) — it needs to
await resolve(event)which runs the entire inner chain, giving accurate total timing - Security headers outermost-last (last) — these operate on the final response object after the route handler has run
A wrong order produces subtle bugs. A rate-limit hook placed after the session hook still validates the session for every request that should have been blocked. A security hook placed before the session hook sets headers on a response before resolve has run, and since hooks mutate the same response object, the headers may be overwritten.
Short-circuiting the chain
A hook does not have to call resolve. If it returns a Response directly, the rest of the chain — and the route handler — never runs. This is the correct way to implement rate limiting, CORS preflight responses, and IP blocking, because you want to reject the request before any downstream work is done.
// A rate-limit hook that short-circuits without calling resolve
const rateLimit: Handle = async ({ event, resolve }) => {
const ip = event.getClientAddress()
const allowed = await checkRateLimit(ip)
if (!allowed) {
// Return a response directly — session, route handler, security headers all skipped
return new Response('Too Many Requests', {
status: 429,
headers: { 'Retry-After': '60' }
})
}
return resolve(event)
}
export const handle = sequence(logging, rateLimit, session, security) When rateLimit returns early, logging still finishes because it wraps the resolve(event) call. session and security never run. This is the right outcome: you want to log that the request was rate-limited, but you do not want to validate a session or set security headers for a response that is simply 429 Too Many Requests.
Request
└─ logging (starts timer)
└─ rateLimit → returns 429 directly ✗ (session and security skipped)
└─ logging (logs "429 Too Many Requests 1ms")
Response Sharing data between hooks via locals
Hooks earlier in the sequence chain can set properties on event.locals that hooks later in the chain read. This is essentially the same mechanism that hooks use to share data with load functions, but applied within the hook pipeline itself.
A common example is multi-tenant resolution: the tenant is identified from the subdomain before session validation runs, because the session table may be tenant-scoped.
// src/hooks.server.ts
const tenant: Handle = async ({ event, resolve }) => {
// Resolve tenant from hostname: acme.yourapp.com → tenantId 'acme'
const hostname = event.request.headers.get('host') ?? ''
const subdomain = hostname.split('.')[0]
event.locals.tenantId = subdomain !== 'www' ? subdomain : null
return resolve(event)
}
const session: Handle = async ({ event, resolve }) => {
event.locals.user = null
const token = event.cookies.get('session')
if (token && event.locals.tenantId) {
// Session validation is tenant-scoped — reads locals.tenantId set by the tenant hook
event.locals.user = await validateSessionForTenant(token, event.locals.tenantId)
}
return resolve(event)
}
export const handle = sequence(logging, tenant, session, security) The tenant hook runs before session in the chain, so by the time session reads event.locals.tenantId it is already set. Load functions downstream can then read both locals.tenantId and locals.user without any knowledge of how they were populated.
Splitting hooks into separate files
For larger applications, define each hook in its own file and import them into hooks.server.ts. This keeps each concern self-contained and independently testable.
src/
hooks.server.ts ← only imports and sequence()
lib/
server/
hooks/
logging.ts ← const logging: Handle
session.ts ← const session: Handle
security.ts ← const security: Handle
rate-limit.ts ← const rateLimit: Handle // src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks'
import { logging } from '$lib/server/hooks/logging'
import { rateLimit } from '$lib/server/hooks/rate-limit'
import { session } from '$lib/server/hooks/session'
import { security } from '$lib/server/hooks/security'
export const handle = sequence(logging, rateLimit, session, security) hooks.server.ts becomes a one-line declaration of intent. Adding or removing a concern is a single import change.
Common Mistakes and Anti-Patterns
Setting locals in +layout.server.ts Instead of the Hook
Because +layout.server.ts has access to event.locals, it is technically possible to write to locals there rather than in the hook. Resist this. SvelteKit runs +layout.server.ts and +page.server.ts simultaneously to maximise parallelism. If the layout load sets locals.user and the page load tries to read it, there is a race condition: the page load may run before the layout has finished writing, reading an uninitialised value.
// Avoid: Setting locals in a layout server load
// src/routes/+layout.server.ts
export const load: LayoutServerLoad = async ({ locals, cookies }) => {
// BAD: writing to locals here is not safe
locals.user = await getUser(cookies.get('session'))
return {}
}
// src/routes/dashboard/+page.server.ts
export const load: PageServerLoad = ({ locals }) => {
// RACE: locals.user may not be set yet because the layout runs in parallel
if (!locals.user) error(401, 'Unauthorised')
} // Preferred: Always set locals in hooks.server.ts, before any load runs
// src/hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = await validateSessionToken(event.cookies.get('session') ?? '')
return resolve(event)
} The hook runs sequentially before any route handler, which means event.locals is fully populated before either +layout.server.ts or +page.server.ts starts. That is the guarantee that makes locals safe to read anywhere in the server-side pipeline.
If you find yourself needing to call await parent() solely to access session data that should have been in locals, that is a strong signal the data is being set in the wrong place.
Throwing a redirect Inside handle
It is tempting to perform authentication redirects inside the handle hook, since it runs for every request. This is technically possible but usually wrong. Redirecting from handle applies to all requests, including static asset requests, SvelteKit’s internal prefetch requests, and API calls from external services. A broad redirect in handle can break things in non-obvious ways.
// Avoid: Redirecting from handle is too broad
export const handle: Handle = async ({ event, resolve }) => {
if (!event.locals.user && !event.url.pathname.startsWith('/login')) {
redirect(302, '/login')
}
return resolve(event)
} // Preferred: Redirect from individual page or layout server load functions,
// using locals.user populated by handle
// src/routes/(protected)/+layout.server.ts
import { redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = ({ locals }) => {
if (!locals.user) redirect(302, '/login')
return { user: locals.user }
} The layout-based guard approach is more surgical. It protects exactly the routes in the (protected) route group, leaves public routes alone, and is easy to reason about by reading the route tree.
Mutating event.locals After resolve
Setting properties on event.locals after calling resolve(event) has no effect on the current request because load functions and handlers have already executed by that point. Locals must be populated before resolve.
// Wrong: Setting locals after resolve
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event)
event.locals.user = await getUser(event.cookies.get('session')) // ← too late
return response
} // Correct: Setting locals before resolve
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = await getUser(event.cookies.get('session')) // ← runs before load
return resolve(event)
} Forgetting to Initialise Locals to null
If a load function reads event.locals.user without the hook always setting it, TypeScript will complain about the property possibly being unset. Always initialise every locals property in the hook, even if to null, to guarantee the shape matches App.Locals:
// Avoid: Conditionally setting — user may be undefined if no cookie
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session')
if (token) {
event.locals.user = await validateSessionToken(token)
}
return resolve(event)
} // Preferred: Always set a value
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = null
const token = event.cookies.get('session')
if (token) {
event.locals.user = await validateSessionToken(token)
}
return resolve(event)
} Performance and Scaling Considerations
The handle hook runs on every single HTTP request to your SvelteKit server. Any asynchronous operation inside it adds latency to every request. Session validation that makes a database query or an API call should be as fast as possible.
For session validation, prefer JWTs with short expiry windows or signed session tokens that can be validated locally without a database round-trip. If you must validate against a database or cache on every request, ensure the query is indexed and consider a fast in-memory cache like Redis to avoid hitting the database for every page view.
The handleFetch hook runs for every fetch call made during SSR, and it can add latency there too. Keep it lightweight. URL rewriting is fast; making additional network calls inside handleFetch would compound latency.
Logging in handleError should be non-blocking where possible. On edge platforms like Cloudflare Workers, you can use event.platform?.context?.waitUntil(promise) to defer non-critical work (such as sending to an error tracker) past the response. On Node.js adapters that API is not available, so prefer writing to a queue or using a fire-and-forget pattern rather than making a synchronous HTTP call to your error tracker inside the hot path.
Conclusion
hooks.server.ts is the single entry point through which every server-side request passes. The handle hook runs first, before any load function or form action, and it is the right place to perform request-scoped setup: session validation, request tracing, feature flag loading. Anything written to event.locals inside the hook is available to every load function, form action, and API handler in the application for the duration of that request.
The type declaration in src/app.d.ts bridges the hook and the load function: whatever shape you declare for App.Locals is the type that event.locals has everywhere in the codebase. Together, the hook and the type declaration replace what would otherwise be repeated session-reading code scattered across dozens of load functions.
handleFetch and handleError round out the lifecycle for the two other key concerns: upstream request modification and unexpected error capture. sequence lets you keep each concern in its own focused function and compose them without nesting.
Key Takeaways
The handle hook in hooks.server.ts intercepts every HTTP request before any route handler runs. Use it to populate event.locals with request-scoped data like the validated session user.
Type App.Locals in src/app.d.ts to get full TypeScript safety across the hook and every server load function that reads from locals. Always initialise every property to a baseline value, typically null, inside the hook.
handleFetch rewrites or enriches server-side fetch calls before they execute, useful for internal URL aliasing and injecting shared auth headers. handleError runs on unexpected exceptions, the right place to report to monitoring services without exposing internal details to the client.
The sequence helper from @sveltejs/kit/hooks composes multiple Handle functions into one, running them in order. Split hooks by concern and compose them cleanly rather than writing one large function.
What’s Next
With locals populated in the hook, every server load function in the application has access to the validated user without any redundant session reads. The next article, Using Parent Data in Load Functions, covers how load functions access data returned by their ancestor layout loads through the parent() function, the waterfall trap that awaiting it too early creates, and the patterns that preserve parallelism while still merging parent and page data.
Further Reading
- SvelteKit Hooks Documentation — official reference for all hook types including
handle,handleFetch,handleError, and thesequencehelper - SvelteKit App.Locals — official reference for typing
event.localswith theApp.Localsinterface inapp.d.ts - Errors and Redirects in Load Functions — how
error()andredirect()work in load functions, complementing thelocals-based guard pattern introduced here