teta.so
SvelteKitHooksMiddlewareServerTutorial

SvelteKit Hooks: Middleware, Auth Guards, and Request Handling

AI Generated

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

  1. Keep hooks focused. Each hook should do one thing well. Use sequence() to compose them instead of building a monolithic handle function.

  2. 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);
};
  1. Use locals for request-scoped data. Do not use module-level variables for per-request state — they are shared across requests in serverless environments.

  2. Type your locals. The App.Locals interface in app.d.ts gives you type safety across hooks, load functions, and form actions.

  3. 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 security
  • handleFetch — Intercept server-side fetches for authentication and URL rewriting
  • handleError — Catch unexpected errors for logging and user-friendly messages
  • sequence() — Compose multiple hooks into a clean, ordered pipeline
  • locals — Type-safe request-scoped data shared between hooks and routes
  • transformPageChunk — 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.

Build your next app with AI

Try Teta — create sites and apps with AI agents.

Get started free