How Load Functions Stop Early

Load functions fail for all kinds of ordinary reasons: a user is looking for a record that does not exist, their session has expired, they do not have permission to view the page, or a downstream API is temporarily down. In each case, continuing to execute the load function and returning incomplete data would cause more problems than it solves. It can result in a broken component, a cryptic runtime error, or worse, silently serving protected content.

SvelteKit gives you two functions to handle these situations cleanly: error() to render an error page, and redirect() to send the user somewhere else. Both are imported from @sveltejs/kit and both work the same way, you call them and they immediately stop the load function by throwing internally. You do not need to write throw yourself.

There is also a third category worth knowing about: errors you did not anticipate at all as a network timeout, a JSON parse failure, a null dereference. SvelteKit catches these too and routes them through a separate handleError hook. The user sees a generic error page; the internal details go to your logging system.

The most important thing to understand before using any of these tools is that redirect() and error() work by throwing. Any try/catch that wraps them will accidentally swallow the signal before SvelteKit sees it. That one mistake (wrapping a redirect in a try/catch) is how route guards silently stop working in production. This article covers all three paths, in the order you are most likely to reach for them.


When a Load Function Cannot Return Data

Imagine a dashboard page. The load function fetches the authenticated user’s data. Three things can happen when a request arrives. The user is authenticated and the data loads cleanly. The user is not authenticated at all, and the right response is to redirect them to a login page. The user is authenticated but the data API returns a 500, and the right response is an error page explaining that something failed.

Each of these outcomes needs a different instruction to SvelteKit. Returning the data is the success path. But for the failure paths, simply returning an empty object or null does not work. The page component would receive incomplete data, try to render it, and produce a broken page or a runtime error that SvelteKit catches as an unexpected error rather than a controlled one.

In SvelteKit we have two tools for controlled failure: error() and redirect(), both imported from @sveltejs/kit. They are not utility functions that return values you then throw yourself. They are functions that throw internally. You call them directly, without throw. When their execution reaches SvelteKit’s internals, the throw terminates the load function and transfers control to the appropriate framework handler.

There is also a third category: errors that you did not intend to throw at all, a network timeout, JSON parse failure, a null dereference et cetera. These are unexpected errors, and SvelteKit routes them through a different mechanism called handleError in hooks.server.ts. Unexpected errors render an error page too, but the information surfaced to the user is deliberately minimal, and the details go to your logging system instead.


The Four Outcomes

Every load function execution ends in one of four ways. Seeing them together before diving into the details makes the rest of the article easier to navigate.

Loading diagram...

The four paths are mutually exclusive. Your responsibility is to choose the right path for each condition and to not accidentally intercept a thrown redirect or error yourself, which is the silent failure this article is most concerned with.


Expected Errors with error()

An expected error is a condition your load function anticipates and explicitly signals. A 404 because a slug does not correspond to any record. A 403 because the user lacks the required permission. A 503 because a dependency is down and you want to communicate that clearly. These are errors you choose to throw.

The error() function takes two arguments:

  1. an HTTP status code
  2. a message.
import { error } from '@sveltejs/kit'
// error(status: number, message: string | { message: string; [key: string]: any })

error(404, 'Not found')

The most commonly used status codes in load functions:

CodeNameWhen to use
400Bad RequestQuery params or route params are malformed or invalid.
401UnauthorizedThe user is not authenticated (no session, expired token).
403ForbiddenThe user is authenticated but lacks permission for this resource.
404Not FoundThe requested record or page does not exist.
410GoneThe resource existed but has been permanently deleted. Prefer over 404 when the removal is intentional and permanent.
429Too Many RequestsThe user or client has exceeded a rate limit.
500Internal Server ErrorA dependency failed in an unexpected way.
503Service UnavailableA required upstream service is temporarily down.

The message can be a string or an object with a message property. SvelteKit throws a HttpError internally and terminates the load function.

// src/routes/blog/[slug]/+page.server.ts

import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ params, fetch }) => {
	const response = await fetch(`/api/blog/posts/${params.slug}`)

	if (response.status === 404) {
		// Signal to SvelteKit that this is an expected, understood condition.
		// The string 'Post not found' will be available in +error.svelte
		// via page.error.message.
		error(404, 'Post not found')
	}

	if (!response.ok) {
		// A non-404 failure is still an expected category here.
		// The status code is passed through so the error page can
		// display or log it appropriately.
		error(response.status, 'Failed to load post')
	}

	const post = await response.json()
	return { post }
}

After error() is called, nothing below it in the load function executes. There is no return value. SvelteKit intercepts the thrown HttpError and begins routing it to the nearest +error.svelte file in the route hierarchy.

The message you pass to error() is intentionally user-facing. Keep it clear and honest but not verbose. It is what your error page will display. It is not a log entry. Do not put stack traces, internal service names, or database error messages into the error() message. Those belong in server-side logs, not in the browser.

The object form of the message is useful when you want to attach additional structured information to the error and read it in the error component:

// src/routes/admin/reports/[reportId]/+page.server.ts

import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ params, locals, fetch }) => {
	if (!locals.user) {
		// A 401 with a structured message object.
		// The error component can read message.code to decide how to render.
		error(401, { message: 'Authentication required', code: 'UNAUTHENTICATED' })
	}

	const response = await fetch(`/api/reports/${params.reportId}`)

	if (response.status === 403) {
		error(403, { message: 'You do not have permission to view this report', code: 'FORBIDDEN' })
	}

	if (response.status === 404) {
		error(404, { message: `Report ${params.reportId} does not exist`, code: 'NOT_FOUND' })
	}

	if (!response.ok) {
		error(500, { message: 'Failed to load report data', code: 'SERVER_ERROR' })
	}

	const report = await response.json()
	return { report }
}

The code field in the message object is not a SvelteKit convention. It is a custom field your application defines. SvelteKit passes the entire message object through to page.error, so whatever shape you give it is available in the error component. Use this consistently across your application so error pages can render contextually appropriate messages.


How SvelteKit Routes to +error.svelte

When error() is thrown from a load function, SvelteKit walks up the route hierarchy looking for the nearest +error.svelte file. The search starts at the same level as the load function that threw and moves toward the root.

Route that throws: src/routes/blog/[slug]/+page.server.ts

SvelteKit looks for +error.svelte in this order:

  1. src/routes/blog/[slug]/+error.svelte     (most specific, checked first)
  2. src/routes/blog/+error.svelte            (blog section level)
  3. src/routes/+error.svelte                 (root level, catches everything)

The first +error.svelte file found in this traversal is the one rendered. If none exists anywhere in the hierarchy, SvelteKit falls back to its built-in error page.

This hierarchy is intentional. A 404 on a blog post does not need to look the same as a 404 on the payment page. By placing +error.svelte files at different levels, you can give sections of your application their own error presentation while still having a root fallback.

Loading diagram...

The +error.svelte component receives error information through the $app/state module’s page store. The page.status field contains the HTTP status code. The page.error field contains the message value passed to error().

<!-- src/routes/+error.svelte -->
<!-- Root-level error page. Handles all unmatched errors. -->

<script lang="ts">
	import { page } from '$app/state'
</script>

<div class="error-container">
	<h1 class="error-status">{page.status}</h1>

	<p class="error-message">
		{page.error?.message ?? 'An unexpected error occurred.'}
	</p>

	{#if page.status === 404}
		<p>The page you are looking for does not exist or has been moved.</p>
	{:else if page.status === 403}
		<p>You do not have permission to access this resource.</p>
	{:else if page.status >= 500}
		<p>Something went wrong on our end. Please try again in a moment.</p>
	{/if}

	<nav class="error-nav">
		<a href="/">Return to home</a>
	</nav>
</div>
<!-- src/routes/blog/+error.svelte -->
<!-- Blog-section error page. Only handles errors thrown from blog routes. -->
<!-- Falls through to +error.svelte at root level if not present here. -->

<script lang="ts">
	import { page } from '$app/state'
</script>

<div class="blog-error">
	<h1>
		{#if page.status === 404}
			Article Not Found
		{:else}
			Something Went Wrong
		{/if}
	</h1>

	<p>{page.error?.message}</p>

	<div class="blog-error-actions">
		<a href="/blog">Browse all articles</a>
		<a href="/">Return to home</a>
	</div>
</div>

An important layout detail: the +error.svelte file is rendered inside the nearest layout that also has its own +error.svelte defined at or above it. It is not rendered in isolation. This means your root layout’s navigation, fonts, and global styles are still present when the error page renders, giving you a coherent user experience without duplicating layout structure in every error component.


Redirects with redirect()

The redirect() function from @sveltejs/kit terminates a load function and instructs SvelteKit to send a redirect response to the browser. Like error(), it works by throwing internally.

The redirect() function takes two arguments:

  1. an HTTP status code — must be in the range 300–308
  2. a destination URL — a path string or full URL
import { redirect } from '@sveltejs/kit'
// redirect(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308, location: string | URL)

redirect(303, '/login')
redirect(301, 'https://example.com/new-url')

Nothing after redirect() executes. The load function ends the moment the redirect is thrown.

The first argument is the HTTP status code. For redirects in load functions, the valid range is 300 to 308. The choice of status code carries semantic meaning that browsers, search engines, and HTTP caches rely on. Picking the wrong one can cause stale cached redirects, broken back-button behavior, or method changes on form submissions.

The most useful codes in SvelteKit load functions are 303 and 307.

Status 303 means “see other” and instructs the client to make a GET request to the new location, regardless of the method used in the original request.

Status 307 means “temporary redirect” and preserves the HTTP method, meaning a POST to a redirected URL will POST to the new location rather than GET it.

For the common case of redirecting a user after authentication (sending an unauthenticated visitor to the login page), 303 is almost always correct. The user arrived via GET. They should be redirected to another GET. Status 308 is the permanent equivalent of 307, appropriate for content that has moved to a canonical URL forever and should be updated in search engine indexes.

CodeNameMethodDurationWhen to use
301Moved PermanentlyGET onlyPermanentThe URL has changed forever. Browsers and search engines cache this — the original URL should never be used again.
302FoundGET only (in practice)TemporaryWidely used but ambiguous. Some old clients preserve the original method; most do not. Avoid in favour of 303 or 307.
303See OtherAlways GETTemporaryDefault choice for auth redirects. Sends unauthenticated users to login. Always results in a GET regardless of the original method.
307Temporary RedirectPreserves methodTemporaryUse when redirecting a POST to another URL that should also receive a POST.
308Permanent RedirectPreserves methodPermanentCanonical URL migrations for endpoints that accept non-GET methods.

For the practical day-to-day of route guards in load functions, choose 303. It is unambiguous, browser-safe, and has the right semantics for authentication redirects.


The try/catch Trap

Now we entering most important section in the article. We discuss a mistake that appears reasonable, because its result doesn’t produce a runtime error or warning, and silently disables your route guard completely in production.

As we know error() and redirect() work by throwing, any try/catch block that wraps them will catch the thrown value and prevent SvelteKit from seeing it. Therefore redirect() or error() never reaches the framework. The load function continues executing (or ends) as if nothing happened.

Here is what that looks like in the context of a route guard:

// Wrong: the try/catch swallows the redirect thrown by redirect().
// If the user is unauthenticated, redirect() is called, throws internally,
// and the catch block catches it. The load function returns undefined.
// SvelteKit receives no redirect instruction. The page loads anyway.

export const load: PageServerLoad = async ({ locals, fetch }) => {
	try {
		if (!locals.user) {
			redirect(303, '/login')
		}

		const response = await fetch(`/api/user/dashboard`)
		const data = await response.json()
		return { dashboard: data }
	} catch (e) {
		// This catches the redirect thrown by redirect().
		// The developer intends this to catch fetch errors.
		// The result is that unauthenticated users see the dashboard.
		console.error('Load failed:', e)
		error(500, 'Failed to load dashboard')
	}
}

The fix it we need restructure the code so that redirect() and error() are called outside of any try/catch, or to re-throw them if a try/catch is genuinely necessary for other error handling:

// Correct: redirect() is called before the try/catch block.
// If the user is unauthenticated, redirect() throws and terminates
// the load function. The try/catch below never runs.

export const load: PageServerLoad = async ({ locals, fetch }) => {
	if (!locals.user) {
		redirect(303, '/login')
	}

	try {
		const response = await fetch(`/api/user/dashboard`)

		if (!response.ok) {
			error(response.status, 'Failed to load dashboard data')
		}

		const data = await response.json()
		return { dashboard: data }
	} catch (e) {
		// This try/catch now only wraps the fetch and JSON parse.
		// redirect() and error() above it are not caught here.
		// The catch block handles genuine unexpected failures like network errors.
		console.error('Unexpected load error:', e)
		error(500, 'An unexpected error occurred')
	}
}

isRedirect type guard

If for some reason you must call redirect() inside a try/catch, you need to re-throw the redirect value after detecting it with the isRedirect type guard from @sveltejs/kit. This is a more error-prone pattern, but it is important to know about it because you may encounter it in existing code:

import { redirect, error, isRedirect } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ locals, fetch }) => {
	try {
		if (!locals.user) {
			redirect(303, '/login')
		}

		const response = await fetch(`/api/user/dashboard`)
		return { dashboard: await response.json() }
	} catch (e) {
		// Re-throw redirect and error instances so SvelteKit can handle them.
		// Only catch genuine unexpected errors here.
		if (isRedirect(e)) throw e

		console.error('Unexpected error:', e)
		error(500, 'Something went wrong')
	}
}

isHttpError type guard

The isHttpError type guard serves the same purpose for error() instances. If you are catching all errors in a load function and want to let expected error() calls pass through, re-throw them alongside redirects:

import { redirect, error, isRedirect, isHttpError } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ params, locals, fetch }) => {
	try {
		if (!locals.user) {
			redirect(303, '/login')
		}

		const response = await fetch(`/api/posts/${params.slug}`)

		if (response.status === 404) {
			error(404, 'Post not found')
		}

		return { post: await response.json() }
	} catch (e) {
		if (isRedirect(e) || isHttpError(e)) throw e

		console.error('Unexpected error:', e)
		error(500, 'Failed to load post')
	}
}

Best Practices

The cleaner solution in most cases is to restructure the code so that guard checks and deliberate error throws happen before the try/catch. The try/catch should only wrap the specific lines of code that can throw unexpected errors, like fetch and response.json(). This way you avoid the risk of accidentally swallowing a redirect or expected error, and your intent is clearer to future readers.

The re-throw pattern exists for situations where restructuring is not practical, but it requires discipline: every catch block that might intercept a redirect or error must explicitly re-throw them. If you forget to re-throw even one, you risk silently breaking your route guards and error handling in a way that only shows up in production.

The rule to memorize: never put redirect() or error() inside a try/catch unless you re-throw redirect and error instances explicitly.


Unexpected Errors

An unexpected error is any error that your load function did not explicitly signal with error(). A failed await on a fetch that throws a network error. These can be: JSON parse failure, null dereference because an API response had a different shape than expected, thrown new Error(...) from a utility function etc. These are errors you did not intend to throw, but that can still happen in the real world. They are not expected, but they are also not impossible. They are the “oops” category of errors.

When an unexpected error occurs, SvelteKit handles it by invoking the handleError hook in src/hooks.server.ts, passing the error object, the request event, a status code, and a message. The hook is responsible for generating any error ID it needs (e.g. via crypto.randomUUID()). After this, SvelteKit renders the nearest +error.svelte with a status code of 500 and a generic, non-specific message.

This generic messaging is intentional. Unexpected errors may include sensitive internal information such as file paths, database queries, service names, or even API keys. Exposing these details in the browser poses security and privacy risks. By default, SvelteKit uses { message: 'Internal Error' } for unexpected errors, ensuring no sensitive information is revealed.

The handleError hook is where you send the real error information somewhere useful, typically a logging or error tracking service:

// src/hooks.server.ts

import type { HandleServerError } from '@sveltejs/kit'

// Note: the export name is 'handleError', not 'handleServerError'.
// 'HandleServerError' is the type; 'handleError' is what SvelteKit looks for.
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
	// 'error' is the raw thrown value. May be an Error instance or anything else.
	// 'event' is the RequestEvent, giving you the URL, headers, and locals.
	// 'status' is the HTTP status code SvelteKit has assigned (usually 500).
	// 'message' is what SvelteKit will show the user ('Internal Error' by default).

	const errorId = crypto.randomUUID()

	// In production, send this to your error tracking service.
	// In development, logging to the console is sufficient.
	console.error({
		errorId,
		status,
		message: error instanceof Error ? error.message : String(error),
		stack: error instanceof Error ? error.stack : undefined,
		url: event.url.pathname,
		method: event.request.method
	})

	// The return value from handleError becomes page.error in +error.svelte.
	// Return something safe that is useful enough for debugging without
	// exposing internal implementation details.
	return {
		message: 'An unexpected error occurred.',
		errorId
	}
}

The return value from handleError replaces the default { message: 'Internal Error' } as the value of page.error. By returning an errorId, the error page can show users a reference number they can provide when contacting support, allowing you to look up the full details in your logging system without ever exposing those details directly in the browser.

The distinction between expected and unexpected errors matters for the user experience.

  • Expected errors should be clear and actionable. A 404 should tell the user the content was not found and offer a path forward.
  • Unexpected errors should be honest about the failure while remaining vague about internals. The handleError hook makes this separation possible.

Practical Example: An Admin Route Guard

An admin section is the cleanest scenario to see redirect(), error(), and the error routing hierarchy working together in the same codebase. Every concept from this article shows up in a concrete, testable form.

The example models three distinct request paths, each producing a different outcome:

Request pathWhoWhat happens
No sid cookieAnonymous visitorredirect(303, '/login?returnTo=...') — sent to login, return URL preserved
sid cookie with role: userAuthenticated, wrong roleerror(403, ...) — admin +error.svelte renders with the 403 message
sid cookie with role: adminAuthenticated, correct roleLoad succeeds, page renders

The example is fully self-contained. The session store and user data are hardcoded — no database or external service required.

Two test accounts are available once you wire up the login page:

Cookie (sid) valueUserRole
session-admin-123Alice Adminadmin — can access /admin
session-user-456Bob Useruser — hits the 403 guard

The full file structure:

src/
  app.d.ts                          ← augment App.Locals
  hooks.server.ts                   ← populate locals from cookie
  routes/
    +layout.server.ts               ← validate session, expose user to all pages
    +layout.svelte                  ← global layout
    +page.svelte                    ← home page
    login/
      +page.server.ts               ← fake login that sets the sid cookie
      +page.svelte                  ← set / clear the sid cookie
    admin/
      +layout.server.ts             ← auth + authz guard
      +layout.svelte                ← admin shell nav
      +error.svelte                 ← section-specific error page
      +page.svelte                  ← admin dashboard index
      users/
        +page.server.ts             ← fetch paginated users
        +page.svelte                ← render user list
    api/
      session/
        validate/
          +server.ts                ← fake session store
      admin/
        users/
          +server.ts                ← fake user data

Step 1 — Type the session into App.Locals

Before writing any load function, tell TypeScript what shape locals.user has. Without this, every access to locals.user in a server load function or hook is an implicit any.

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

This one declaration makes locals.user fully typed everywhere — hooks, layout loads, page loads. Adjust the fields to match whatever your real session API returns.

Step 2 — Populate locals once per request in hooks.server.ts

The handle hook in hooks.server.ts runs before every request resolves. It is the right place to validate the session cookie and attach the user to event.locals — not inside a layout load.

The reason is directly related to the try/catch trap. If session validation lived inside a layout load wrapped in try/catch for fetch error handling, a carelessly placed redirect() or error() call could be swallowed. Running it in handle, before any load function executes, means the validated user is available in locals by the time any load function reads it. The load function can then call redirect() or error() cleanly, with no try/catch anywhere near them.

// src/hooks.server.ts

import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
	// Skip validation for the session endpoint itself to prevent
	// infinite recursion — event.fetch re-enters this hook.
	if (event.url.pathname === '/api/session/validate') {
		return resolve(event)
	}

	const sessionId = event.cookies.get('sid')

	if (sessionId) {
		const response = await event.fetch('/api/session/validate', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ sessionId })
		})

		if (response.ok) {
			const { user } = await response.json()
			event.locals.user = user
		}
	}

	return resolve(event)
}

If the cookie is missing or invalid, event.locals.user is never set. Because App.Locals declares it as { ... } | null, TypeScript will enforce that every load function handles the null case.

Step 3 — Expose the user to public pages in the root layout

The root layout load runs for every route in the application. It fetches the session a second time — this time to populate data.user for components in the root +layout.svelte. Notice it does not call redirect() or error() here, because most routes are public. Unauthenticated visitors should be able to reach the home page without being redirected.

// src/routes/+layout.server.ts

import type { LayoutServerLoad } from './$types'

export const load: LayoutServerLoad = async ({ cookies, fetch }) => {
	const sessionId = cookies.get('sid')

	if (!sessionId) {
		return { user: null }
	}

	const response = await fetch('/api/session/validate', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ sessionId })
	})

	if (!response.ok) {
		return { user: null }
	}

	const { user } = await response.json()
	return { user }
}

Returning { user: null } is the correct pattern for a public route that has no required authentication. The redirect and error calls belong in the loads that actually need to restrict access.

Step 4 — The admin layout guard: where redirect() and error() both appear

This is the central file of the example. The admin +layout.server.ts is where the two failure paths from this article meet in a single load function — redirect() for unauthenticated users, error() for authenticated users without the required role.

// src/routes/admin/+layout.server.ts

import type { LayoutServerLoad } from './$types'
import { redirect, error } from '@sveltejs/kit'

export const load: LayoutServerLoad = async ({ locals, url }) => {
	// Authentication check — no session means no business being here.
	// redirect(303) sends the user to login and preserves the current URL
	// as a returnTo param so they land back here after signing in.
	if (!locals.user) {
		redirect(303, `/login?returnTo=${encodeURIComponent(url.pathname)}`)
	}

	// Authorization check — the user IS authenticated, but lacks the role.
	// This is NOT a redirect to login. Their session is valid.
	// Sending them to login would imply the session is broken, which is wrong.
	// error(403) is the accurate signal: authenticated, not permitted.
	if (locals.user.role !== 'admin') {
		error(403, 'You do not have permission to access the admin section.')
	}

	// Both checks passed. Return the user for child layouts and pages to use.
	return { adminUser: locals.user }
}

Notice what is not here: no try/catch. The redirect() and error() calls are at the top level of the load function, not wrapped in anything that could intercept the throws. This is exactly the pattern described in the try/catch trap section — guard checks first, risky operations (if any) after, in a separate try/catch that does not surround the guards.

Because this is a layout load, SvelteKit runs it before executing any page load under /admin. If the guard fails, the page load never runs. By the time +page.server.ts files under /admin execute, locals.user is guaranteed to exist and have the admin role.

Step 5 — An admin page load that relies on the guard

Page loads under /admin can skip re-checking authentication and authorization. The layout guard already ran. They only need to handle the specific data-fetching failures their own fetch calls can produce.

// src/routes/admin/users/+page.server.ts

import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ url, fetch }) => {
	const page = Number(url.searchParams.get('page') ?? '1')
	const perPage = 25

	const response = await fetch(`/api/admin/users?page=${page}&perPage=${perPage}`)

	// error() here is a controlled failure for a fetch problem.
	// It is not inside a try/catch — it sits at the top level of the load.
	if (!response.ok) {
		error(response.status, 'Failed to load user list')
	}

	const { users, total } = await response.json()

	return {
		users,
		pagination: {
			page,
			perPage,
			total,
			totalPages: Math.ceil(total / perPage)
		}
	}
}

Step 6 — The admin-specific +error.svelte

Placing a +error.svelte inside the admin/ directory gives the admin section its own error page. When error(403, ...) is thrown from the admin layout guard, SvelteKit walks up the route hierarchy and finds this file before reaching the root-level +error.svelte.

<!-- src/routes/admin/+error.svelte -->

<script lang="ts">
	import { page } from '$app/state'
</script>

<div class="admin-error">
	<h1 class="admin-error-status">Error {page.status}</h1>
	<p class="admin-error-message">{page.error?.message}</p>

	{#if page.status === 403}
		<p>If you believe you should have access to this section, contact a superadmin.</p>
	{/if}

	<a href="/admin" class="admin-error-back">Return to admin dashboard</a>
</div>

page.status and page.error.message contain exactly what was passed to error() in the layout guard: 403 and 'You do not have permission to access the admin section.'. The error component reads them directly — no props, no additional fetch.

This file also renders inside the admin layout, so the nav shell stays visible when a 403 occurs. An authenticated user who hits the forbidden page still sees the application frame, not a blank page. If you want the error to render outside the admin layout — for example, to prevent a partially-rendered admin nav from confusing the user — remove this file and let the error fall through to src/routes/+error.svelte.

The supporting files

The remaining files are scaffolding that lets you exercise the guard in a browser without a real database.

The admin layout component renders the nav shell and the child page content:

<!-- src/routes/admin/+layout.svelte -->

<script lang="ts">
	import type { LayoutProps } from './$types'

	let { data, children }: LayoutProps = $props()
</script>

<div class="admin-shell">
	<nav class="admin-nav">
		<span class="admin-badge">Admin</span>
		<span class="admin-user">{data.adminUser.name}</span>
		<ul class="admin-links">
			<li><a href="/admin">Dashboard</a></li>
			<li><a href="/admin/users">Users</a></li>
			<li><a href="/admin/reports">Reports</a></li>
			<li><a href="/admin/settings">Settings</a></li>
		</ul>
	</nav>

	<main class="admin-content">
		{@render children()}
	</main>
</div>

data.adminUser is available here because the layout guard returned { adminUser: locals.user }. If the guard had redirected or thrown, this component would never render.

The admin dashboard and users pages display the data returned by their respective load functions:

<!-- src/routes/admin/+page.svelte -->

<script lang="ts">
	import type { PageProps } from './$types'

	let { data }: PageProps = $props()
</script>

<div class="admin-dashboard">
	<h1>Admin Dashboard</h1>
	<p>Welcome, {data.adminUser.name}.</p>

	<nav class="admin-quick-links">
		<a href="/admin/users">Manage Users</a>
	</nav>
</div>
<!-- src/routes/admin/users/+page.svelte -->

<script lang="ts">
	import type { PageProps } from './$types'

	let { data }: PageProps = $props()
</script>

<div class="admin-users">
	<h1>Users</h1>
	<p>{data.pagination.total} total</p>

	<table>
		<thead>
			<tr>
				<th>ID</th>
				<th>Name</th>
				<th>Email</th>
				<th>Role</th>
			</tr>
		</thead>
		<tbody>
			{#each data.users as user (user.id)}
				<tr>
					<td>{user.id}</td>
					<td>{user.name}</td>
					<td>{user.email}</td>
					<td>{user.role}</td>
				</tr>
			{/each}
		</tbody>
	</table>

	<div class="admin-pagination">
		{#if data.pagination.page > 1}
			<a href="/admin/users?page={data.pagination.page - 1}">← Previous</a>
		{/if}
		<span>Page {data.pagination.page} of {data.pagination.totalPages}</span>
		{#if data.pagination.page < data.pagination.totalPages}
			<a href="/admin/users?page={data.pagination.page + 1}">Next →</a>
		{/if}
	</div>
</div>

The login page sets the sid cookie directly to simulate authentication. It reads the returnTo query param so the user lands back on the page they were trying to reach. After a successful login action, redirect(303, returnTo) sends them there. After logout, redirect(303, '/') brings them back to the home page.

<!-- src/routes/login/+page.svelte -->

<script lang="ts">
	// Two hardcoded session IDs map to the fake session store in
	// src/routes/api/session/validate/+server.ts
	const sessions = [
		{ label: 'Alice Admin (role: admin)', value: 'session-admin-123' },
		{ label: 'Bob User (role: user)', value: 'session-user-456' }
	]
</script>

<div class="login-demo">
	<h1>Demo Login</h1>
	<p>Pick an account. This sets the <code>sid</code> cookie directly — no real auth.</p>

	{#each sessions as session (session.value)}
		<form method="POST" action="/login?/login">
			<input type="hidden" name="sid" value={session.value} />
			<input
				type="hidden"
				name="returnTo"
				value={new URLSearchParams(globalThis?.location?.search ?? '').get('returnTo') ?? '/admin'}
			/>
			<button type="submit">{session.label}</button>
		</form>
	{/each}

	<form method="POST" action="/login?/logout">
		<button type="submit">Clear cookie (log out)</button>
	</form>
</div>
// src/routes/login/+page.server.ts

import type { Actions } from './$types'
import { redirect } from '@sveltejs/kit'

export const actions: Actions = {
	login: async ({ request, cookies }) => {
		const data = await request.formData()
		const sid = data.get('sid') as string
		const returnTo = (data.get('returnTo') as string) || '/admin'

		cookies.set('sid', sid, {
			path: '/',
			httpOnly: true,
			sameSite: 'lax',
			maxAge: 60 * 60 * 24 // 1 day
		})

		redirect(303, returnTo)
	},

	logout: async ({ cookies }) => {
		cookies.delete('sid', { path: '/' })
		redirect(303, '/')
	}
}

The fake session API maps the two hardcoded session IDs to user objects. In a real application this would query a database or cache:

// src/routes/api/session/validate/+server.ts

import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

const sessions: Record<string, { id: string; name: string; role: string }> = {
	'session-admin-123': { id: '1', name: 'Alice Admin', role: 'admin' },
	'session-user-456': { id: '2', name: 'Bob User', role: 'user' }
}

export const POST: RequestHandler = async ({ request }) => {
	const { sessionId } = await request.json()
	const user = sessions[sessionId as string] ?? null

	if (!user) {
		return json({ user: null }, { status: 401 })
	}

	return json({ user })
}

The fake admin users API returns a paginated slice of hardcoded users:

// src/routes/api/admin/users/+server.ts

import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'

const FAKE_USERS = [
	{ id: '1', name: 'Alice Admin', email: 'alice@example.com', role: 'admin' },
	{ id: '2', name: 'Bob User', email: 'bob@example.com', role: 'user' },
	{ id: '3', name: 'Carol Editor', email: 'carol@example.com', role: 'editor' },
	{ id: '4', name: 'Dave Viewer', email: 'dave@example.com', role: 'user' },
	{ id: '5', name: 'Eve Moderator', email: 'eve@example.com', role: 'moderator' }
]

export const GET: RequestHandler = async ({ url }) => {
	const page = Number(url.searchParams.get('page') ?? '1')
	const perPage = Number(url.searchParams.get('perPage') ?? '25')
	const start = (page - 1) * perPage
	const users = FAKE_USERS.slice(start, start + perPage)

	return json({ users, total: FAKE_USERS.length })
}

Common Mistakes and Anti-Patterns

Wrapping redirect() in try/catch without re-throwing

This is the central mistake of the article. It is worth repeating because the failure is silent.

// Wrong: redirect() is caught by the catch block.
// The load function does not redirect. The page loads as if the
// guard was never there.
export const load: PageServerLoad = async ({ locals }) => {
	try {
		if (!locals.user) {
			redirect(303, '/login')
		}
		const data = await someRiskyOperation()
		return { data }
	} catch (e) {
		return { data: null }
	}
}
// Correct: redirect() is outside the try/catch,
// or re-thrown if it must appear inside one.
export const load: PageServerLoad = async ({ locals }) => {
	if (!locals.user) {
		redirect(303, '/login')
	}

	try {
		const data = await someRiskyOperation()
		return { data }
	} catch (e) {
		return { data: null }
	}
}

Returning null or empty data instead of calling error()

// Avoid: returning null or partial data when a resource is missing.
// The page component receives null and likely throws a runtime error,
// which becomes an unexpected 500 instead of a clean 404.
export const load: PageServerLoad = async ({ params, fetch }) => {
	const response = await fetch(`/api/posts/${params.slug}`)

	if (!response.ok) {
		return { post: null }
	}

	return { post: await response.json() }
}
// Preferred: throw a structured error that SvelteKit can route
// to the appropriate +error.svelte file.
export const load: PageServerLoad = async ({ params, fetch }) => {
	const response = await fetch(`/api/posts/${params.slug}`)

	if (response.status === 404) {
		error(404, 'Post not found')
	}

	if (!response.ok) {
		error(response.status, 'Failed to load post')
	}

	return { post: await response.json() }
}

Returning null for a missing resource puts the burden of the “not found” state on every component that uses the data. Each component must defensively check for null before rendering. If any component fails to check, the error is a cryptic “Cannot read properties of null” rather than a clear 404 page. Using error() centralizes the handling and produces a far better user experience.

Using redirect() for authorization failures

// Avoid: redirecting to login when the user IS authenticated but lacks permission.
// Redirecting to login implies the session is invalid, which is incorrect.
// The user will log in and be redirected back to the same forbidden page.
export const load: PageServerLoad = async ({ locals }) => {
	if (locals.user?.role !== 'admin') {
		redirect(303, '/login')
	}
	// ...
}
// Preferred: use error(403) for authorization failures.
// The user is authenticated. Their session is valid. They simply
// lack the required permission. A 403 communicates that accurately.
export const load: PageServerLoad = async ({ locals }) => {
	if (!locals.user) {
		redirect(303, '/login')
	}

	if (locals.user.role !== 'admin') {
		error(403, 'You do not have permission to access this resource.')
	}
	// ...
}

The distinction between authentication (who you are) and authorization (what you are allowed to do) maps directly to the choice between redirect() and error(). Missing authentication calls for a redirect to login. Insufficient authorization calls for a 403 error. Conflating them confuses users and makes the application harder to debug.

Exposing internal error details through error()

// Wrong: passing internal details into error() where they reach the browser.
// Database errors, file paths, and internal service names are security concerns.
export const load: PageServerLoad = async ({ params, fetch }) => {
	const response = await fetch(`/api/posts/${params.slug}`)

	if (!response.ok) {
		const body = await response.json()
		// body.internalError might contain 'PostgreSQL: relation "posts" does not exist at line 1'
		error(500, body.internalError)
	}
	// ...
}
// Correct: pass user-safe messages to error(). Log the internal details
// server-side through handleError or direct logging.
export const load: PageServerLoad = async ({ params, fetch }) => {
	const response = await fetch(`/api/posts/${params.slug}`)

	if (!response.ok) {
		// Log internally if needed through your logging service.
		// Show the user only what helps them understand what happened.
		error(500, 'Failed to load post data. Please try again.')
	}
	// ...
}

Performance and Scaling Considerations

The error and redirect paths in SvelteKit are not free from a performance standpoint, but they are generally not a bottleneck either. A few patterns are worth considering as your application scales.

Route guards in layout loads run for every request to every route in that section. A root-level authentication check that makes a network request to validate a session adds that latency to every page load. The hooks.server.ts handle function is a better place for session validation because it runs once per request and populates locals with the validated user, which layout loads can read without making their own network request. The admin layout example above uses locals.user rather than calling the session API directly, for exactly this reason.

The +error.svelte component is rendered using the same server-side rendering pipeline as normal pages. If your error page makes its own data fetches, those fetches execute on every error. Keep error pages simple and self-contained. They should render from page.status and page.error without making additional API calls.

For high-traffic applications with many route guards, consider whether your authorization logic belongs in hooks.server.ts rather than scattered across multiple layout load functions. Centralizing the check into a single handle function means it runs once, populates locals, and every load function reads from locals without a network round trip. The layout load still calls error() or redirect() based on what it finds in locals, but the expensive session validation happens only once.


What’s Next

Now that you have seen all the major load function shapes, server, universal, page, layout, and their error and redirect paths, the right moment to look closely at the TypeScript layer has arrived. The generated $types do more than just type the load function argument; they track the full data shape across merged layouts, provide typed params, and detect mismatches between what a load function returns and what a component expects. The next article, TypeScript and $types in Load Functions, covers the complete type system around SvelteKit data loading.


See Also