3 min read
On this page

Hooks & Handle

Every request that hits a SvelteKit server passes through hooks.server.ts before reaching any route. The handle function is SvelteKit's middleware layer. It intercepts requests, can modify them, and decides how they proceed. This is where you add authentication checks, logging, rate limiting, CORS headers, and any cross-cutting concern that applies to multiple routes.

The Handle Function

The handle function receives an event and a resolve function. You call resolve to continue processing the request through the normal SvelteKit pipeline:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  const start = Date.now();

  // Before the route runs
  console.log(`→ ${event.request.method} ${event.url.pathname}`);

  // Let SvelteKit handle the request normally
  const response = await resolve(event);

  // After the route runs
  const duration = Date.now() - start;
  console.log(`← ${response.status} ${event.url.pathname} (${duration}ms)`);

  return response;
};

The pattern is: do something before resolve, call resolve to get the response, optionally modify the response, then return it. If you never call resolve, the route handler never executes. This gives you full control over the request lifecycle.

The Event Object

The event parameter carries everything about the incoming request:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  // The raw Request object (Web Standards API)
  const method = event.request.method;
  const headers = event.request.headers;
  const body = event.request.body;

  // Parsed URL with pathname, searchParams, etc.
  const path = event.url.pathname;
  const query = event.url.searchParams.get('q');

  // Cookie management — get, set, delete
  const sessionId = event.cookies.get('session');

  // locals — a mutable object that persists through the request
  // Set data here, read it in load functions and actions
  event.locals.requestId = crypto.randomUUID();

  // platform — adapter-specific bindings (Cloudflare KV, D1, etc.)
  // Only available when running on the target platform
  const kv = event.platform?.env?.MY_KV;

  // Route info
  const routeId = event.route.id; // e.g., '/blog/[slug]'

  // Client IP address (depends on adapter and proxy configuration)
  const ip = event.getClientAddress();

  const response = await resolve(event);
  return response;
};

The locals object is the primary way to pass data from hooks to load functions and actions. It is request-scoped: each request gets its own locals that is garbage collected after the response is sent.

Authentication Middleware

The most common use of handle is checking authentication:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { verifySession } from '$lib/server/auth';

export const handle: Handle = async ({ event, resolve }) => {
  const sessionToken = event.cookies.get('session');

  if (sessionToken) {
    const user = await verifySession(sessionToken);
    if (user) {
      event.locals.user = user;
    } else {
      // Invalid session — clear the cookie
      event.cookies.delete('session', { path: '/' });
    }
  }

  return resolve(event);
};

This runs on every request. If a valid session cookie exists, the user data is attached to event.locals. Load functions and actions can then check event.locals.user to know who is making the request.

You need to declare the locals type so TypeScript knows about it:

// src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: {
        id: string;
        name: string;
        email: string;
        role: 'admin' | 'user';
      } | null;
    }
  }
}

export {};

Logging Middleware

Structured request logging that captures timing, status codes, and request metadata:

// src/lib/server/logger.ts
import type { Handle } from '@sveltejs/kit';

export const loggingHandle: Handle = async ({ event, resolve }) => {
  const requestId = crypto.randomUUID();
  event.locals.requestId = requestId;

  const start = performance.now();
  const response = await resolve(event);
  const duration = (performance.now() - start).toFixed(2);

  const log = {
    requestId,
    method: event.request.method,
    path: event.url.pathname,
    status: response.status,
    duration: `${duration}ms`,
    userAgent: event.request.headers.get('user-agent')
  };

  if (response.status >= 500) {
    console.error(JSON.stringify(log));
  } else {
    console.log(JSON.stringify(log));
  }

  return response;
};

Rate Limiting

A simple in-memory rate limiter. In production, use a distributed store like Redis or Cloudflare KV:

// src/lib/server/rate-limit.ts
import type { Handle } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';

const requests = new Map<string, { count: number; resetAt: number }>();

export const rateLimitHandle: Handle = async ({ event, resolve }) => {
  if (!event.url.pathname.startsWith('/api')) {
    return resolve(event);
  }

  const ip = event.getClientAddress();
  const now = Date.now();
  const window = 60_000; // 1 minute
  const limit = 100;

  let record = requests.get(ip);
  if (!record || record.resetAt < now) {
    record = { count: 0, resetAt: now + window };
    requests.set(ip, record);
  }

  record.count++;

  if (record.count > limit) {
    error(429, 'Too many requests');
  }

  const response = await resolve(event);

  // Add rate limit headers
  response.headers.set('X-RateLimit-Limit', limit.toString());
  response.headers.set('X-RateLimit-Remaining', (limit - record.count).toString());

  return response;
};

Modifying the Response

The resolve function accepts options to transform the response:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
  const response = await resolve(event, {
    // Transform the HTML before sending it
    transformPageChunk: ({ html }) => {
      return html.replace('%lang%', getLocale(event));
    },

    // Filter which headers are serialized for server-side rendering
    filterSerializedResponseHeaders: (name) => {
      return name === 'content-type' || name === 'cache-control';
    },

    // Preload specific asset types
    preload: ({ type }) => {
      return type === 'font' || type === 'js' || type === 'css';
    }
  });

  return response;
};

Sequencing Multiple Handles

Real applications need multiple middleware functions. SvelteKit provides sequence to compose them:

// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';

const authHandle: Handle = async ({ event, resolve }) => {
  const token = event.cookies.get('session');
  if (token) {
    event.locals.user = await verifySession(token);
  }
  return resolve(event);
};

const corsHandle: Handle = async ({ event, resolve }) => {
  if (event.request.method === 'OPTIONS') {
    return new Response(null, {
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization'
      }
    });
  }

  const response = await resolve(event);
  response.headers.set('Access-Control-Allow-Origin', '*');
  return response;
};

const securityHandle: 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;
};

// Runs in order: auth → cors → security → route handler
export const handle = sequence(authHandle, corsHandle, securityHandle);

sequence chains the handles so each one wraps the next. The first handle's resolve calls the second handle, and so on until the actual route handler runs.

Common Pitfalls

  • Forgetting to call resolve. If you return a response without calling resolve(event), the route handler never executes. This is intentional for redirects and error responses, but a bug if you forget it on the happy path.
  • Mutating the request body in hooks. The request body is a ReadableStream that can only be consumed once. If you read it in a hook, the route handler gets an empty body. Clone the request first if you need to inspect the body.
  • Expensive operations on every request. The handle function runs on every request, including static assets if not served by a CDN. Keep it fast. Use early returns for paths that do not need processing.
  • Not declaring the Locals type. Without the App.Locals interface in app.d.ts, TypeScript does not know about your custom locals properties. You will get type errors in load functions.
  • Wrong sequence order. The order of handles in sequence matters. Put authentication first if other middleware depends on event.locals.user. Put logging first if you want to time everything including auth.

Key Takeaways

  • hooks.server.ts exports a handle function that runs on every server request before the route handler.
  • The event object provides request, url, cookies, locals, platform, and route.
  • event.locals is the way to pass data from hooks to load functions and actions. It is request-scoped.
  • Call resolve(event) to continue to the route handler. Skip it to short-circuit with your own response.
  • Use sequence from @sveltejs/kit/hooks to compose multiple middleware functions in order.
  • Declare custom locals types in src/app.d.ts for TypeScript support.
  • Keep hooks fast. They run on every request, so expensive operations add latency to every page load.