08 - Hooks
📋 Jump to TakeawaysWhat are Hooks?
Every request to your SvelteKit app goes through hooks before reaching any page or API route. They're the place for things that apply globally: checking if a user is logged in, logging requests, handling errors, adding headers.
Hooks live in special files at the src/ root:
| File | Runs on | Purpose |
|---|---|---|
src/hooks.server.ts |
Server | Auth, logging, modify requests/responses |
src/hooks.client.ts |
Client | Client-side error handling |
Client Hooks
handleError
For errors that happen in the browser. Things like a component crashing during rendering or an unhandled promise rejection:
// src/hooks.client.ts
import type { HandleClientError } from '@sveltejs/kit';
export const handleError: HandleClientError = async ({ error }) => {
console.error('Client error:', error);
return {
message: 'An error occurred'
};
};That's the only client hook. Server hooks are where most of the action is.
Server Hooks
handle
Think of handle as middleware. Every single request passes through it before reaching any page or API route. This is where you check if a user is logged in, add logging, or block certain requests.
You get the request (event), do whatever you need, then call resolve(event) to let SvelteKit continue to the actual page. You can also modify the response after:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// BEFORE: runs before the route handler
console.log(`Request: ${event.url.pathname}`);
// Add data to event.locals — available in load functions and actions
event.locals.user = await getUser(event.cookies);
const response = await resolve(event); // process the route
// AFTER: runs after the route handler
response.headers.set('X-Custom-Header', 'value');
return response;
};event.locals is a per-request object you can attach data to. It's available in all load functions and form actions for that request.
By default, TypeScript doesn't know what's inside event.locals. To define its type, add it to src/app.d.ts:
// src/app.d.ts
declare global {
namespace App {
interface Locals {
user: { id: number; name: string } | null;
}
}
}
export {};Now event.locals.user is typed everywhere.
handleFetch
When a load function calls fetch() during SSR, the request goes through handleFetch. This is useful when your API requires authentication headers that only the server knows, or when you need to rewrite URLs for internal services:
// src/hooks.server.ts
import type { HandleFetch } from '@sveltejs/kit';
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
// Add auth headers to API calls made during SSR
if (request.url.startsWith('https://api.example.com')) {
request.headers.set('Authorization', 'Bearer token');
}
return fetch(request);
};This only affects server-side fetch calls. Client-side fetch in the browser is not affected.
handleError
When something unexpected breaks (a load function throws, a database query fails), handleError catches it. Use it to log errors to a service and control what the user sees:
// src/hooks.server.ts
import type { HandleServerError } from '@sveltejs/kit';
export const handleError: HandleServerError = async ({ error, event }) => {
// Log to your error tracking service (Sentry, LogRocket, etc.)
console.error('Server error:', error);
// This object becomes $page.error in +error.svelte
return {
message: 'Something went wrong'
};
};Never expose the real error to the user. Log the details server-side, return a friendly message.
Authentication Example
The most common use of server hooks. Check for a session cookie, load the user, and protect routes:
// src/hooks.server.ts
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// 1. Check session cookie
const session = event.cookies.get('session');
// 2. Load user if session exists
// Replace with your actual database/auth logic
if (session) {
event.locals.user = await getUserBySession(session);
}
// 3. Protect routes — redirect to login if not authenticated
if (event.url.pathname.startsWith('/dashboard')) {
if (!event.locals.user) {
redirect(303, '/login');
}
}
return resolve(event);
};Then access locals.user in any load function:
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return { user: locals.user }; // available in all pages via data.user
};This is exactly how ByteLearn handles admin authentication.
Sequencing Multiple Hooks
As your app grows, putting everything in one handle function gets messy. sequence() lets you split concerns into separate functions that run in order:
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
const auth: Handle = async ({ event, resolve }) => {
event.locals.user = await getUser(event);
return resolve(event);
};
const logger: Handle = async ({ event, resolve }) => {
console.log(event.url.pathname);
return resolve(event);
};
// auth runs first, then logger
export const handle = sequence(auth, logger);Each function is focused on one job. Easier to read, test, and reorder.
Transform HTML
You can modify the rendered HTML before sending it to the client. A common use is replacing placeholders in app.html:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
return html.replace('%lang%', 'en');
}
});
return response;
};This lets you set things like the page language dynamically based on the user's preferences.
Key Takeaways
handleis middleware for every request. Use it for auth, logging, and headers.event.localspasses data from hooks to load functions and actionshandleFetchintercepts server-side fetch calls (add auth headers, rewrite URLs)handleErrorcatches unhandled errors. Log the details, return a friendly message.sequence()splits multiple hooks into focused, composable functions- Hooks are global. They affect every route in the app.