Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 024 / 100

Hooks in SvelteKit: a practical Svelte & SvelteKit guide

Run code for every request.

Expona Admin7 min read

Running code for every request in SvelteKit

Hooks in SvelteKit give you a place to run code before every request hits your routes. Hooks in SvelteKit: a practical Svelte & SvelteKit guide walks through the server-side hooks that let you authenticate users, attach data to requests, catch errors globally, and compose multiple middleware functions. If you've worked with Express middleware or similar request pipelines in other frameworks, SvelteKit hooks follow a familiar pattern—but with some important differences we'll cover here.

handle — Your first line of defense for every request

The handle hook runs on the server for every incoming request. It receives an event object containing request details, and it must return a response. This is where most teams attach authentication, logging, or request metadata.

Here's a realistic setup where we fetch the current user from a session cookie and attach it to the request:

typescript
// src/hooks.server.ts
import { getUser } from '$lib/auth'

export const handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get('session')
  
  if (sessionId) {
    event.locals.user = await getUser(sessionId)
  } else {
    event.locals.user = null
  }
  
  return resolve(event)
}

The resolve(event) call tells SvelteKit to continue processing—routing to your page, calling load functions, and so on. Anything you attach to event.locals will be available downstream in your load functions and server actions.

A common production scenario: you want to add request tracing. Attach a request ID to every request, then include it in logs:

typescript
import { v4 as uuid } from 'uuid'

export const handle = async ({ event, resolve }) => {
  event.locals.requestId = uuid()
  
  const startTime = Date.now()
  const response = await resolve(event)
  const duration = Date.now() - startTime
  
  console.log(`[${event.locals.requestId}] ${event.request.method} ${event.url.pathname} - ${duration}ms`)
  
  return response
}

This simple approach gives you observability across your entire application without touching individual routes.

locals — Sharing request data with loads and actions

The event.locals object is your bridge between hooks and your page/component code. Anything you store there is available in page load functions and server actions within that request.

Let's extend our authentication example. After fetching the user in handle, access it in a load function:

typescript
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals }) => {
  if (!locals.user) {
    throw error(401, 'Unauthorized')
  }
  
  return {
    user: locals.user,
    dashboard: await getDashboardData(locals.user.id)
  }
}

Or in a server action:

typescript
// src/routes/profile/+page.server.ts
import { fail } from '@sveltejs/kit'

export const actions = {
  updateProfile: async ({ request, locals }) => {
    if (!locals.user) {
      return fail(401, { message: 'Not authenticated' })
    }
    
    const formData = await request.formData()
    await updateUserProfile(locals.user.id, Object.fromEntries(formData))
    
    return { success: true }
  }
}

locals is request-scoped, so each request gets its own isolated copy. This prevents data leaking between concurrent requests—a critical property for server-side rendering.

handleError — Global error logging and transformation

The handleError hook fires whenever an error is thrown in a load function, server action, or route handler. Use it to log unexpected errors, transform them for the client, or send them to an error tracking service.

Basic setup:

typescript
// src/hooks.server.ts
import * as Sentry from '@sentry/sveltekit'

export const handleError = async ({ error, event }) => {
  const errorId = crypto.randomUUID()
  
  console.error(`[${errorId}] Error in ${event.request.method} ${event.url.pathname}`, error)
  
  Sentry.captureException(error, {
    tags: { requestId: event.locals.requestId }
  })
  
  // Return a safe message to the client
  return {
    message: 'An unexpected error occurred',
    errorId
  }
}

This logs the full error server-side but returns a generic message to the browser. The errorId lets users report "Error ABC123" so you can search logs and find the root cause.

In a real system, you might differentiate between expected errors and bugs:

typescript
import { HttpError } from '$lib/errors'

export const handleError = async ({ error, event }) => {
  if (error instanceof HttpError) {
    // Expected error: send it as-is
    return {
      message: error.message,
      status: error.status
    }
  }
  
  // Unexpected bug: log and anonymize
  const errorId = crypto.randomUUID()
  console.error(`[${errorId}]`, error)
  
  return {
    message: 'An unexpected error occurred',
    errorId
  }
}

sequence — Composing multiple hooks

Real applications often need multiple middleware functions. SvelteKit's sequence helper composes them into a single handle.

Imagine you have authentication, request logging, and CORS headers as separate concerns:

typescript
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks'
import { authenticateUser } from '$lib/auth'
import { logRequest } from '$lib/logging'

const auth = async ({ event, resolve }) => {
  event.locals.user = await authenticateUser(event.cookies.get('session'))
  return resolve(event)
}

const logging = async ({ event, resolve }) => {
  const start = Date.now()
  const response = await resolve(event)
  const duration = Date.now() - start
  
  logRequest({
    method: event.request.method,
    path: event.url.pathname,
    duration,
    userId: event.locals.user?.id
  })
  
  return response
}

const cors = async ({ event, resolve }) => {
  const response = await resolve(event)
  
  if (event.request.headers.get('origin') === 'https://trusted-domain.com') {
    response.headers.set('Access-Control-Allow-Origin', '*')
  }
  
  return response
}

export const handle = sequence(auth, logging, cors)

Each hook runs in order. The resolve(event) call passes control to the next hook in the chain, finally reaching your route handler. The response flows back through the chain in reverse—so cors can modify headers added by other hooks.

Order matters. Put authentication early so downstream hooks can trust event.locals.user. Put logging near the end so it captures the final response.

Common mistakes

  • Blocking requests unnecessarily: If you're doing database queries in handle for every request, consider caching or deferring to load functions instead. handle runs for static assets too, so keep it fast.
  • Mutating locals without thinking about types: TypeScript won't catch typos in event.locals.userId. Declare your locals shape in app.d.ts to get type safety.
  • Forgetting to return resolve(event): If you forget to call or return it, your routes never execute. Always return a response from handle.
  • Mixing client and server hooks: handle is server-only. For client-side setup, use hooks.client.ts instead—different file, different purpose.
  • Not setting locals for unauthenticated users: If you only set event.locals.user when authenticated, downstream code must check for undefined. Always initialize locals with a default.

Typing locals in app.d.ts

To get TypeScript autocomplete on event.locals, declare its shape:

typescript
// src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: { id: string; email: string } | null
      requestId: string
    }
  }
}

export {}

Now TypeScript will warn you if you try to access event.locals.userId (typo) instead of event.locals.user.id.

Key takeaways

  • Use handle for authentication, logging, and any logic that needs to run on every request.
  • Attach data to event.locals to share it with load functions and actions.
  • Use handleError for global error handling—log server-side, return safe messages to the client.
  • Compose multiple hooks with sequence to keep concerns separate and testable.
  • Declare your locals shape in app.d.ts for full TypeScript support.
  • Keep handle fast—it runs on every request, including static assets.
  • Remember that locals is request-scoped; each request gets its own isolated copy.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles