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:
- Load functions → Throw with
error()→ Caught by error pages - Form actions → Return with
fail()→ Handled inline in forms - Components → Wrapped with
<svelte:boundary>→ Show fallback UI - 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
- Throw early: In load functions, throw as soon as you know something’s wrong.
- Fail explicitly: In form actions, return
fail()with helpful error messages. - Catch at boundaries: Use error pages and
<svelte:boundary>to handle errors gracefully. - Log everything: Track errors in production to find and fix them.
- 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.