Errors Are Inevitable

Errors are inevitable. Networks fail. Databases timeout. Users submit invalid data. The question isn’t whether errors happen—it’s how your app responds when they do.

Scattered try/catch blocks create chaos. Error handling logic mixed with business logic. Forgotten catch blocks that swallow errors silently. Inconsistent error messages across the app.

The calm approach: throw errors at the boundary where they occur, catch them at the boundary where you can handle them. Everything in between stays clean.

Two Kinds of Errors

Expected errors are part of normal operation:

  • Validation failures
  • Not found (404)
  • Unauthorized (401, 403)
  • Business rule violations

You anticipate these. You handle them gracefully. The user sees a helpful message.

Unexpected errors are bugs or infrastructure failures:

  • Database connection lost
  • Null pointer exception
  • Out of memory

You don’t anticipate these specifically. You catch them generically. The user sees “Something went wrong.”

Handle them differently.

Throwing in Load Functions

Load functions are the boundary between your app and external data. Throw errors here:

Previously, you had to throw the values returned from error(…) and redirect(…) yourself. In SvelteKit 2 this is no longer the case — calling the functions is sufficient. This is a great improvement. It makes the intent clear and reduces boilerplate.

// routes/projects/[id]/+page.server.ts
import { error } from '@sveltejs/kit'
import { db } from '$lib/server/db'

export async function load({ params, locals }) {
	// Auth check
	if (!locals.user) {
		error(401, 'You must be logged in')
	}

	// Fetch data
	const project = await db.getProject(params.id)

	// Not found
	if (!project) {
		error(404, 'Project not found')
	}

	// Authorization
	if (project.ownerId !== locals.user.id) {
		error(403, 'You do not have access to this project')
	}

	return { project }
}

SvelteKit catches these errors and renders the appropriate error page. Your component code stays clean—it only runs when the data loaded successfully.

Error Pages

Create +error.svelte to handle errors:

<!-- routes/+error.svelte -->
<script>
	import { page } from '$app/stores'
</script>

<div class="error-page">
	<h1>{$page.status}</h1>
	<p>{$page.error?.message || 'Something went wrong'}</p>
	<a href="/">Go home</a>
</div>

Error pages inherit from layouts, so your app shell stays intact. The user sees an error within your design, not a blank page.

Nested error pages

Create error pages at different levels for different handling:

routes/
├── +error.svelte           # Catch-all error page
├── +layout.svelte          # App shell
├── projects/
│   ├── +error.svelte       # Project-specific errors
│   └── [id]/
│       └── +page.svelte
└── admin/
    ├── +error.svelte       # Admin-specific errors
    └── +page.svelte

A 404 in /projects/123 uses routes/projects/+error.svelte. If that doesn’t exist, it bubbles up to routes/+error.svelte.

Custom error content

Different errors deserve different treatment:

<!-- routes/+error.svelte -->
<script>
	import { page } from '$app/stores'
</script>

{#if $page.status === 404}
	<div class="not-found">
		<h1>Page not found</h1>
		<p>The page you're looking for doesn't exist.</p>
		<a href="/">Go home</a>
	</div>
{:else if $page.status === 403}
	<div class="forbidden">
		<h1>Access denied</h1>
		<p>You don't have permission to view this page.</p>
		<a href="/dashboard">Go to dashboard</a>
	</div>
{:else}
	<div class="generic-error">
		<h1>Something went wrong</h1>
		<p>We're working on it. Please try again later.</p>
		<button onclick={() => location.reload()}>Retry</button>
	</div>
{/if}

Handling Form Errors

Form actions handle expected errors differently—you want to show errors inline, not redirect to an error page:

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

export const actions = {
	updateProfile: async ({ request, locals }) => {
		const formData = await request.formData()
		const name = formData.get('name')
		const email = formData.get('email')

		// Validation errors
		const errors: Record<string, string> = {}

		if (!name || typeof name !== 'string' || name.length < 2) {
			errors.name = 'Name must be at least 2 characters'
		}

		if (!email || typeof email !== 'string' || !email.includes('@')) {
			errors.email = 'Valid email required'
		}

		if (Object.keys(errors).length > 0) {
			return fail(400, { errors, name, email })
		}

		// Business logic errors
		try {
			await db.updateUser(locals.user.id, { name, email })
		} catch (e) {
			if (e.code === 'EMAIL_EXISTS') {
				return fail(400, {
					errors: { email: 'This email is already in use' },
					name,
					email
				})
			}
			throw e // Unexpected error, let it bubble up
		}
		// TODO: check throw e --- IGNORE ---
		return { success: true }
	}
}
<!-- routes/settings/+page.svelte -->
<script>
	import { enhance } from '$app/forms'

	let { data, form } = $props()
</script>

{#if form?.success}
	<div class="success">Profile updated!</div>
{/if}

<form method="POST" action="?/updateProfile" use:enhance>
	<label>
		Name
		<input
			name="name"
			value={form?.name ?? data.user.name}
			aria-invalid={form?.errors?.name ? 'true' : undefined}
		/>
		{#if form?.errors?.name}
			<span class="error">{form.errors.name}</span>
		{/if}
	</label>

	<label>
		Email
		<input
			name="email"
			type="email"
			value={form?.email ?? data.user.email}
			aria-invalid={form?.errors?.email ? 'true' : undefined}
		/>
		{#if form?.errors?.email}
			<span class="error">{form.errors.email}</span>
		{/if}
	</label>

	<button type="submit">Save</button>
</form>

fail() returns data to the page without redirecting. The form repopulates with submitted values and shows inline errors.

Component-Level Error Boundaries

For errors in rendering (not data loading), use <svelte:boundary>:

<script>
  import RiskyComponent from './RiskyComponent.svelte';
</script>

<svelte:boundary>
  <RiskyComponent />

  {#snippet failed(error, reset)}
    <div class="component-error">
      <p>This component failed to load.</p>
      <button onclick={reset}>Try again</button>
    </div>
  {/snippet}
</svelte:boundary>

If RiskyComponent throws during rendering, the failed snippet shows instead. The rest of the page continues working.

Use this for:

  • Third-party components you don’t control
  • Components that depend on unpredictable data
  • Features where partial failure is acceptable

Don’t overuse it. Most components should just work. Boundaries are for isolation, not for hiding bugs.

The Error Handling Hierarchy

Think of error handling as a hierarchy:

  1. Load functions → Throw with error() → Caught by error pages
  2. Form actions → Return with fail() → Handled inline in forms
  3. Components → Wrapped with <svelte:boundary> → Show fallback UI
  4. Unexpected errors → Bubble up → Caught by root error page

Each level handles what it can. What it can’t handle bubbles up.

Logging and Monitoring

Errors should be logged, not just displayed:

// hooks.server.ts
import { error as kitError } from '@sveltejs/kit'

export async function handleError({ error, event }) {
	// Log the error with context
	console.error('Unhandled error:', {
		message: error.message,
		stack: error.stack,
		url: event.url.pathname,
		method: event.request.method,
		userId: event.locals.user?.id
	})

	// In production, send to error tracking service
	// await errorTracker.capture(error, { event });

	// Return a safe error message to the client
	return {
		message: 'An unexpected error occurred'
	}
}

The handleError hook catches errors that reach the top. Log them with context. Send them to your error tracking service. Return a safe message to users (don’t leak stack traces).

The Pattern

  1. Throw early: In load functions, throw as soon as you know something’s wrong.
  2. Fail explicitly: In form actions, return fail() with helpful error messages.
  3. Catch at boundaries: Use error pages and <svelte:boundary> to handle errors gracefully.
  4. Log everything: Track errors in production to find and fix them.
  5. Keep components clean: Components assume data is valid. Error handling happens at the edges.

This keeps error handling organized. You know where to look when something goes wrong. And your happy-path code stays readable.


Next up: We’ve covered what to do. Now let’s cover what not to do—the patterns that seem smart but cause pain.