SvelteKit Hooks: Middleware, Auth Guards, and Request Handling
SvelteKit Hooks: Middleware, Auth Guards, and Request Handling
SvelteKit's hook system is one of its most powerful features, yet it is often underutilized. Hooks let you intercept and modify every request and response that flows through your application. Think of them as middleware — but with a more focused, composable design that avoids the waterfall complexity of traditional middleware chains.
In this tutorial, you will learn how to use every hook SvelteKit provides. We will build practical examples including authentication guards, error handling, request logging, CORS configuration, and rate limiting. By the end, you will understand how to compose these hooks together using sequence() for a clean, maintainable server-side pipeline.
Understanding the Hook Types
SvelteKit provides four hook types, each serving a different purpose:
handle— Runs on every server request. This is the main hook for middleware logic.handleFetch— Intercepts fetch requests made in load functions on the server.handleError— Catches unexpected errors and lets you process them.init— Runs once when the server starts (SvelteKit 2.12+).
All server hooks live in src/hooks.server.ts. Client hooks go in src/hooks.client.ts.
Step 1: The Handle Hook — Your Main Middleware
The handle hook receives every request and must return a response. At its simplest, it just passes the request through:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// Before the route handler runs
console.log(`${event.request.method} ${event.url.pathname}`);
// Process the request through the route handler
const response = await resolve(event);
// After the route handler has run
response.headers.set('X-Custom-Header', 'SvelteKit');
return response;
};
The event object contains everything about the request: URL, headers, cookies, the platform context, and locals — a mutable object for passing data to load functions.
The resolve function processes the request through your routes and returns a response. You can modify the response before returning it.
Step 2: Using Locals to Pass Data
The event.locals object is how hooks communicate with load functions and form actions. Type it in src/app.d.ts:
declare global {
namespace App {
interface Locals {
user: {
id: string;
email: string;
role: 'admin' | 'user';
} | null;
requestId: string;
startTime: number;
}
}
}
export {};
Populate locals in your hook:
export const handle: Handle = async ({ event, resolve }) => {
// Generate a unique request ID for tracing
event.locals.requestId = crypto.randomUUID();
event.locals.startTime = Date.now();
// Parse auth token and set user
const token = event.cookies.get('session');
if (token) {
event.locals.user = await validateToken(token);
} else {
event.locals.user = null;
}
const response = await resolve(event);
// Add request ID to response headers for debugging
response.headers.set('X-Request-Id', event.locals.requestId);
return response;
};
Access locals in any load function:
// src/routes/dashboard/+page.server.ts
export const load: PageServerLoad = async ({ locals }) => {
// locals.user is fully typed
if (!locals.user) {
redirect(303, '/login');
}
return {
user: locals.user,
requestId: locals.requestId
};
};
Step 3: Build an Authentication Guard
A robust authentication guard checks the session, validates it, and redirects unauthenticated users:
import { redirect, type Handle } from '@sveltejs/kit';
const PUBLIC_ROUTES = [
'/login',
'/signup',
'/forgot-password',
'/auth/callback',
'/api/webhooks'
];
const ADMIN_ROUTES = ['/admin'];
const authGuard: Handle = async ({ event, resolve }) => {
const { pathname } = event.url;
// Allow public routes without authentication
const isPublicRoute = PUBLIC_ROUTES.some(route =>
pathname === route || pathname.startsWith(route + '/')
);
if (isPublicRoute) {
return resolve(event);
}
// Check for a valid session
if (!event.locals.user) {
// Store the intended destination for post-login redirect
const redirectTo = encodeURIComponent(pathname + event.url.search);
redirect(303, `/login?redirectTo=${redirectTo}`);
}
// Check admin routes
const isAdminRoute = ADMIN_ROUTES.some(route =>
pathname.startsWith(route)
);
if (isAdminRoute && event.locals.user.role !== 'admin') {
redirect(303, '/dashboard');
}
return resolve(event);
};
Step 4: Error Handling with handleError
The handleError hook catches unexpected errors (not errors you throw intentionally with error() or redirect()). Use it for logging, error tracking, and providing user-friendly error messages:
import type { HandleServerError } from '@sveltejs/kit';
export const handleError: HandleServerError = async ({
error,
event,
status,
message
}) => {
// Generate a unique error ID for support tickets
const errorId = crypto.randomUUID().slice(0, 8);
// Log the full error for debugging
console.error(`[${errorId}] Error ${status} on ${event.url.pathname}:`, error);
// Send to error tracking service (Sentry, LogRocket, etc.)
// await reportError({ errorId, error, url: event.url.pathname, status });
// Return a safe message to the user (never expose internal details)
return {
message: status === 404
? 'The page you are looking for does not exist.'
: 'Something went wrong. Please try again later.',
errorId
};
};
Create a matching error page at src/routes/+error.svelte:
<script lang="ts">
import { page } from '$app/state';
</script>
<div class="flex flex-col items-center justify-center min-h-screen p-6">
<h1 class="text-6xl font-bold text-gray-300 mb-4">
{page.status}
</h1>
<p class="text-xl text-gray-600 mb-2">
{page.error?.message ?? 'Something went wrong'}
</p>
{#if page.error?.errorId}
<p class="text-sm text-gray-400">
Error reference: {page.error.errorId}
</p>
{/if}
<a href="/" class="mt-6 text-blue-600 hover:underline">
Go back home
</a>
</div>
Step 5: Intercept Server-Side Fetches with handleFetch
The handleFetch hook intercepts fetch calls made inside load functions on the server. This is useful for adding authentication headers, rewriting URLs, or bypassing CORS for internal APIs:
import type { HandleFetch } from '@sveltejs/kit';
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
// Add auth token to internal API requests
if (request.url.startsWith('https://api.internal.example.com')) {
const token = event.cookies.get('api_token');
request.headers.set('Authorization', `Bearer ${token}`);
}
// Rewrite external URLs to internal ones for server-side requests
if (request.url.startsWith('https://api.example.com')) {
request = new Request(
request.url.replace(
'https://api.example.com',
'http://api-internal:3000'
),
request
);
}
return fetch(request);
};
Step 6: CORS Handling
For APIs that need to be accessed from other domains:
const corsHandler: Handle = async ({ event, resolve }) => {
// Only apply CORS to API routes
if (!event.url.pathname.startsWith('/api/')) {
return resolve(event);
}
const allowedOrigins = [
'https://myapp.com',
'https://staging.myapp.com'
];
const origin = event.request.headers.get('origin');
const isAllowed = origin && allowedOrigins.includes(origin);
// Handle preflight requests
if (event.request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
}
});
}
const response = await resolve(event);
if (isAllowed) {
response.headers.set('Access-Control-Allow-Origin', origin!);
response.headers.set('Access-Control-Allow-Credentials', 'true');
}
return response;
};
Step 7: Request Logging
A logging hook that tracks request timing and status:
const requestLogger: Handle = async ({ event, resolve }) => {
const start = performance.now();
const requestId = event.locals.requestId ?? crypto.randomUUID().slice(0, 8);
const response = await resolve(event);
const duration = Math.round(performance.now() - start);
const status = response.status;
const method = event.request.method;
const path = event.url.pathname;
// Color-coded logging based on status
const statusColor = status >= 500 ? 'ERROR' :
status >= 400 ? 'WARN' :
status >= 300 ? 'REDIRECT' : 'OK';
console.log(
`[${requestId}] ${method} ${path} -> ${status} ${statusColor} (${duration}ms)`
);
// Add server timing header for performance monitoring
response.headers.set('Server-Timing', `total;dur=${duration}`);
return response;
};
Step 8: Rate Limiting
A simple in-memory rate limiter for API routes:
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
const rateLimiter: Handle = async ({ event, resolve }) => {
// Only rate-limit API routes
if (!event.url.pathname.startsWith('/api/')) {
return resolve(event);
}
const clientIp = event.getClientAddress();
const now = Date.now();
const windowMs = 60_000; // 1 minute window
const maxRequests = 60; // 60 requests per minute
const current = rateLimitMap.get(clientIp);
if (!current || now > current.resetTime) {
rateLimitMap.set(clientIp, { count: 1, resetTime: now + windowMs });
} else if (current.count >= maxRequests) {
return new Response(
JSON.stringify({ error: 'Too many requests' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((current.resetTime - now) / 1000))
}
}
);
} else {
current.count++;
}
const response = await resolve(event);
const limit = rateLimitMap.get(clientIp);
if (limit) {
response.headers.set('X-RateLimit-Limit', String(maxRequests));
response.headers.set('X-RateLimit-Remaining',
String(Math.max(0, maxRequests - limit.count))
);
}
return response;
};
Note: For production applications, use a Redis-backed rate limiter. The in-memory approach shown here does not work with multiple server instances.
Step 9: Compose Hooks with sequence()
The sequence() function from SvelteKit lets you compose multiple handle hooks into a single pipeline. Each hook runs in order, and each can modify the request or short-circuit the chain:
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
// Individual hooks defined above...
const supabaseAuth: Handle = async ({ event, resolve }) => {
// Set up Supabase client and user in locals
// (see our auth tutorial for full implementation)
return resolve(event);
};
const securityHeaders: 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');
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
return response;
};
// Compose them in order
export const handle = sequence(
requestLogger, // Log every request
securityHeaders, // Add security headers
corsHandler, // Handle CORS for API routes
rateLimiter, // Rate limit API routes
supabaseAuth, // Set up auth
authGuard // Protect routes
);
The order matters. Place logging first so it captures all requests. Place auth setup before auth guards so the guard has access to the user. Place CORS handling before auth so preflight requests are not blocked.
Step 10: Transform HTML Responses
The resolve function accepts options for transforming the response. This is useful for injecting data into the HTML:
export const handle: Handle = async ({ event, resolve }) => {
const theme = event.cookies.get('theme') ?? 'light';
return resolve(event, {
transformPageChunk({ html }) {
// Inject theme class into the HTML element
return html.replace(
'<html',
`<html class="${theme}"`
);
},
filterSerializedResponseHeaders(name) {
// Allow specific headers through to the client
return name === 'content-range' || name === 'x-total-count';
}
});
};
Client-Side Hooks
SvelteKit also provides client-side hooks in src/hooks.client.ts:
// src/hooks.client.ts
import type { HandleClientError } from '@sveltejs/kit';
export const handleError: HandleClientError = async ({ error, event, status, message }) => {
const errorId = crypto.randomUUID().slice(0, 8);
// Log to console in development
console.error(`[${errorId}]`, error);
// Report to error tracking in production
if (!import.meta.env.DEV) {
// await reportToSentry({ errorId, error, url: event.url.href });
}
return {
message: 'An unexpected error occurred.',
errorId
};
};
Best Practices
Keep hooks focused. Each hook should do one thing well. Use
sequence()to compose them instead of building a monolithic handle function.Fail open for non-critical hooks. If your logging hook throws, it should not break the user's request. Wrap non-critical logic in try-catch:
const safeLogger: Handle = async ({ event, resolve }) => {
try {
console.log(event.url.pathname);
} catch {
// Logging failure should not break the request
}
return resolve(event);
};
Use locals for request-scoped data. Do not use module-level variables for per-request state — they are shared across requests in serverless environments.
Type your locals. The
App.Localsinterface inapp.d.tsgives you type safety across hooks, load functions, and form actions.Test hooks independently. Since hooks are just functions, you can test them with mock event objects without running the full SvelteKit server.
Summary
SvelteKit's hook system gives you complete control over request processing:
handle— The main middleware hook for auth, logging, CORS, and securityhandleFetch— Intercept server-side fetches for authentication and URL rewritinghandleError— Catch unexpected errors for logging and user-friendly messagessequence()— Compose multiple hooks into a clean, ordered pipelinelocals— Type-safe request-scoped data shared between hooks and routestransformPageChunk— Modify HTML responses before they reach the client
Master these hooks and you can handle any server-side requirement without reaching for external middleware libraries. The composable design keeps your code clean even as your requirements grow.
Source: Teta Engineering
This content was generated with AI from public sources. It represents analysis and commentary, not original journalism.