Svelte 5 & SvelteKit Development
Complete guide for modern Svelte 5 and SvelteKit development with runes, routing, and best practices.
Core Svelte 5 Concepts
Runes - The New Reactivity System
Svelte 5 introduces runes - compiler symbols prefixed with $ that provide explicit, universal reactivity. They work in .svelte, .svelte.js, and .svelte.ts files.
$state - Reactive State
<script>
// Basic state
let count = $state(0);
// Object state (deeply reactive)
let user = $state({
name: 'Alice',
age: 30
});
// Array state
let items = $state([1, 2, 3]);
</script>
<button onclick={() => count++}>
Count: {count}
</button>
Best Practices:
- •Use
$statefor all reactive variables - •Objects and arrays are deeply reactive - mutations work
- •For raw state (non-reactive): use
$state.raw() - •No need to wrap class instances
$derived - Computed Values
<script>
let count = $state(0);
let doubled = $derived(count * 2);
// For complex computations, use $derived.by
let total = $derived.by(() => {
let sum = 0;
for (const n of numbers) {
sum += n;
}
return sum;
});
</script>
Best Practices:
- •Use
$derivedfor computed values, NOT$effect - •Keep derived expressions pure (no side effects)
- •No state changes inside
$derived- will error - •Automatically memoized - only recalculates when dependencies change
$effect - Side Effects
<script>
let count = $state(0);
$effect(() => {
console.log(`Count is now ${count}`);
// Cleanup function (optional)
return () => {
console.log('Cleanup before next run');
};
});
// Runs before DOM updates
$effect.pre(() => {
// Code here runs before DOM updates
});
</script>
Best Practices:
- •Use
$effectfor side effects ONLY (DOM manipulation, logging, external APIs) - •Use
$derivedfor computing values - •Effects run after component mount and when dependencies change
- •Return cleanup functions for subscriptions/listeners
Common Anti-patterns to AVOID:
// ❌ BAD: Using $effect to compute values
$effect(() => {
doubled = count * 2; // Should be $derived
});
// ✅ GOOD: Use $derived for computation
let doubled = $derived(count * 2);
// ❌ BAD: State changes in $derived
let bad = $derived(count++); // Will error
// ✅ GOOD: Pure computation
let good = $derived(count + 1);
$props - Component Props
<script>
// Destructure props
let { name, age = 18, optional } = $props();
// With TypeScript
interface Props {
name: string;
age?: number;
}
let { name, age = 18 }: Props = $props();
// Bindable props
let { value = $bindable(0) } = $props();
</script>
Best Practices:
- •Use
$props()instead ofexport let(Svelte 4 syntax) - •Provide default values inline
- •Use
$bindable()for two-way binding - •TypeScript interfaces improve type safety
Component Patterns
Event Handlers
<script>
let count = $state(0);
// Events are now just props
let { onclick } = $props();
function handleClick() {
count++;
onclick?.(); // Call parent handler if provided
}
</script>
<button {onclick}>Click me</button>
<!-- Parent component -->
<MyButton onclick={() => console.log('clicked')} />
Best Practices:
- •Events are props now - no
createEventDispatcher() - •Use optional chaining for optional event handlers
- •Can check if handler exists:
if (onclick) { ... }
Snippets - Reusable Markup
<script>
let items = $state(['a', 'b', 'c']);
</script>
{#snippet card(title, content)}
<div class="card">
<h3>{title}</h3>
<p>{content}</p>
</div>
{/snippet}
{#each items as item}
{@render card(item, `Content for ${item}`)}
{/each}
Lifecycle Hooks
<script>
import { onMount, onDestroy } from 'svelte';
// Still available and recommended for lifecycle
onMount(() => {
console.log('Component mounted');
return () => {
console.log('Cleanup on unmount');
};
});
// Note: beforeUpdate and afterUpdate are deprecated
// Use $effect instead when you need reactive cleanup
</script>
SvelteKit Architecture
File-based Routing
src/routes/
├── +page.svelte # / route
├── +layout.svelte # Root layout
├── +layout.server.ts # Server layout load
├── about/
│ └── +page.svelte # /about
├── blog/
│ ├── +page.svelte # /blog
│ ├── [slug]/
│ │ └── +page.svelte # /blog/[slug]
│ └── [...catchall]/
│ └── +page.svelte # /blog/* (catch-all)
├── (authed)/ # Route group (no URL segment)
│ ├── +layout.svelte # Shared layout
│ ├── dashboard/
│ │ └── +page.svelte # /dashboard
│ └── settings/
│ └── +page.svelte # /settings
└── api/
└── posts/
└── +server.ts # API endpoint
Key Files:
- •
+page.svelte- Page component - •
+page.ts- Universal load function (runs on server and client) - •
+page.server.ts- Server-only load function - •
+layout.svelte- Layout wrapper - •
+layout.ts/+layout.server.ts- Layout data loading - •
+server.ts- API endpoints (GET, POST, etc.) - •
+error.svelte- Error boundary
Data Loading Patterns
Server Load Functions
// +page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, fetch, cookies }) => {
// Runs only on server
// Has access to: database, environment variables, cookies
const post = await db.getPost(params.slug);
return {
post
};
};
Universal Load Functions
// +page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, fetch, data }) => {
// Runs on server AND client
// Can access parent data via 'data'
// Use for client-safe operations
const response = await fetch(`/api/posts/${params.slug}`);
const post = await response.json();
return {
post
};
};
Best Practices:
- •Use
+page.server.tsfor database access, secrets, server-only logic - •Use
+page.tsfor API calls, client-safe operations - •Universal loads can access server load data via
dataparam - •Parent layout data accessible via
await parent() - •Use SvelteKit's enhanced
fetchfor automatic request tracking
Form Actions & Remote Functions
Traditional Form Actions
// +page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
// Default action
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email');
if (!email) {
return fail(400, { email, missing: true });
}
// Process data...
redirect(303, '/success');
},
// Named action
login: async ({ request }) => {
const data = await request.formData();
// Handle login...
return { success: true };
}
};
<!-- +page.svelte -->
<script>
import { enhance } from '$app/forms';
let { data, form } = $props();
</script>
<!-- Default action -->
<form method="POST" use:enhance>
<input name="email" />
{#if form?.missing}
<p class="error">Email is required</p>
{/if}
<button>Submit</button>
</form>
<!-- Named action -->
<form method="POST" action="?/login" use:enhance>
<input name="username" />
<button>Login</button>
</form>
Remote Functions (New Pattern)
// data.remote.ts
import { query, form } from '@sveltejs/kit';
import { z } from 'zod';
// Remote query
export const getPosts = query(async () => {
return await db.posts.findMany();
});
// Remote form with Zod validation
const createPostSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(10, 'Content must be at least 10 characters')
});
export const createPost = form(createPostSchema, async (data) => {
const post = await db.posts.create(data);
// Refresh queries
getPosts.refresh();
return { success: true, post };
});
<!-- +page.svelte -->
<script>
import { createPost, getPosts } from './data.remote';
let posts = getPosts();
</script>
<form {...createPost}>
<input name="title" />
<textarea name="content"></textarea>
<button>Create</button>
</form>
{#each posts.data as post}
<article>{post.title}</article>
{/each}
Form Action Best Practices:
- •Use
use:enhancefor progressive enhancement - •Return validation errors with
fail() - •Use
redirect()for navigation after success - •Named actions for multiple forms:
action="?/actionName" - •Access form data in page via
formprop - •Use Zod for schema validation in remote functions
API Endpoints
// +server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, params }) => {
const data = await fetchData(params.id);
if (!data) {
error(404, 'Not found');
}
return json(data);
};
export const POST: RequestHandler = async ({ request }) => {
const data = await request.json();
// Process...
return json({ success: true }, { status: 201 });
};
Page Options
// +page.ts or +page.server.ts export const prerender = true; // Static generation export const ssr = false; // Disable SSR (SPA mode) export const csr = true; // Enable client-side rendering
Common Patterns
State Management
// stores.svelte.ts - Shared state with runes
export function createCounter(initial = 0) {
let count = $state(initial);
let doubled = $derived(count * 2);
return {
get count() { return count; },
get doubled() { return doubled; },
increment: () => count++,
decrement: () => count--
};
}
// Usage in components
import { createCounter } from './stores.svelte';
const counter = createCounter();
Handling Forms with Progressive Enhancement
<script>
import { enhance } from '$app/forms';
let { form } = $props();
let loading = $state(false);
</script>
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ result, update }) => {
loading = false;
await update();
};
}}
>
<input name="email" />
{#if form?.errors}
<p class="error">{form.errors.email}</p>
{/if}
<button disabled={loading}>
{loading ? 'Submitting...' : 'Submit'}
</button>
</form>
Error Handling
<!-- +error.svelte -->
<script>
import { page } from '$app/state';
</script>
<h1>{page.status}</h1>
<p>{page.error.message}</p>
Navigation
<script>
import { goto } from '$app/navigation';
function navigate() {
goto('/dashboard');
}
</script>
<!-- Use native <a> for links -->
<a href="/about">About</a>
Migration from Svelte 4
What Changed
- •
export let→$props() - •
let(reactive) →$state() - •
$:statements →$derived()or$effect() - •
createEventDispatcher→ Props/callbacks - •Stores still work but runes preferred for new code
Quick Migration Example
<!-- Svelte 4 -->
<script>
export let name;
let doubled;
$: doubled = count * 2;
$: console.log(count);
</script>
<!-- Svelte 5 -->
<script>
let { name } = $props();
let doubled = $derived(count * 2);
$effect(() => console.log(count));
</script>
Anti-patterns to Avoid
❌ Don't use $effect for computed values
// Bad
$effect(() => {
total = a + b;
});
// Good
let total = $derived(a + b);
❌ Don't mutate state inside $derived
// Bad let bad = $derived(count++); // Good let good = $derived(count + 1);
❌ Don't use old Svelte 4 patterns in new code
// Bad
export let prop;
$: doubled = count * 2;
// Good
let { prop } = $props();
let doubled = $derived(count * 2);
❌ Don't forget to use .server files for sensitive operations
// Bad: +page.ts (runs on client!)
export const load = async () => {
const secret = process.env.SECRET_KEY; // Exposed to client!
};
// Good: +page.server.ts
export const load = async () => {
const secret = process.env.SECRET_KEY; // Server-only
};
Performance Tips
- •Use
$derivedover$effectfor computations - More efficient - •Leverage fine-grained reactivity - Only affected parts re-render
- •Use
$state.raw()for non-reactive data - Avoid unnecessary tracking - •Prerender static pages - Set
export const prerender = true - •Lazy load components - Use dynamic imports for code splitting
- •Use SvelteKit's enhanced fetch - Automatic deduplication and caching
TypeScript Integration
// Component with typed props
<script lang="ts">
interface Props {
title: string;
count?: number;
onClick?: () => void;
}
let { title, count = 0, onClick }: Props = $props();
// Derived with type inference
let doubled: number = $derived(count * 2);
</script>
// Load function types
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
// Fully typed
};
Recommended Project Structure
src/ ├── lib/ │ ├── components/ │ │ ├── ui/ # Reusable UI components │ │ └── features/ # Feature-specific components │ ├── stores.svelte.ts # Shared reactive state │ ├── utils/ # Utility functions │ └── server/ # Server-only code ├── routes/ │ ├── +layout.svelte │ ├── +page.svelte │ └── ... └── app.html
Key Takeaways
- •Runes are universal - Work in components AND
.svelte.jsfiles - •
$derivedfor values,$effectfor side effects - Clear separation - •SvelteKit's routing is file-based - Conventions over configuration
- •Progressive enhancement by default - Forms work without JS
- •Type safety is built-in - Use TypeScript for best experience
- •Server vs client is explicit -
.server.tsfiles are server-only - •Use Zod for validation - Type-safe schema validation with excellent inference
Resources
- •Official Docs: https://svelte.dev/docs
- •SvelteKit Docs: https://svelte.dev/docs/kit
- •Migration Guide: https://svelte.dev/docs/svelte/v5-migration-guide
- •Zod Documentation: https://zod.dev