Skip to content

Svelte/SvelteKit

Write reactive, performant Svelte applications that leverage the framework’s compiler-based approach and SvelteKit’s full-stack capabilities.

Structure components with clear sections:

<script lang="ts">
// 1. Imports
import { onMount } from 'svelte';
import Button from '$lib/components/Button.svelte';
// 2. Props (with types)
interface Props {
title: string;
count?: number;
}
let { title, count = 0 }: Props = $props();
// 3. State
let isLoading = $state(false);
let items = $state<Item[]>([]);
// 4. Derived state
let total = $derived(items.length);
let isEmpty = $derived(items.length === 0);
// 5. Functions
function handleClick() {
count++;
}
// 6. Effects
$effect(() => {
console.log('Count changed:', count);
});
// 7. Lifecycle
onMount(() => {
fetchItems();
});
</script>
<!-- 8. Template -->
<div class="container">
<h1>{title}</h1>
<p>Count: {count}</p>
{#if isEmpty}
<p>No items</p>
{:else}
<ul>
{#each items as item (item.id)}
<li>{item.name}</li>
{/each}
</ul>
{/if}
<Button onclick={handleClick}>
Increment
</Button>
</div>
<!-- 9. Styles -->
<style>
.container {
padding: 1rem;
}
h1 {
color: var(--primary);
}
</style>

Use $state rune for reactive state:

<script lang="ts">
// Primitive state
let count = $state(0);
// Object state
let user = $state({
name: 'John',
email: 'john@example.com'
});
// Array state
let items = $state<string[]>([]);
// State with type inference
let data = $state<User | null>(null);
function updateUser() {
// Mutate directly - no need for assignments
user.name = 'Jane';
items.push('new item');
}
</script>

Use $derived for computed values:

<script lang="ts">
let count = $state(0);
// Simple derived
let doubled = $derived(count * 2);
// Derived with logic
let status = $derived(
count === 0 ? 'empty' :
count < 10 ? 'low' :
'high'
);
// Derived from multiple sources
let firstName = $state('John');
let lastName = $state('Doe');
let fullName = $derived(`${firstName} ${lastName}`);
</script>

Use $effect for side effects:

<script lang="ts">
let count = $state(0);
// Basic effect
$effect(() => {
console.log('Count changed:', count);
});
// Effect with cleanup
$effect(() => {
const interval = setInterval(() => {
count++;
}, 1000);
return () => {
clearInterval(interval);
};
});
// Pre-effect (runs before DOM updates)
$effect.pre(() => {
console.log('Before update');
});
</script>

Use $props for component props:

<script lang="ts">
interface Props {
title: string;
subtitle?: string;
count?: number;
onclick?: () => void;
}
// Destructure with defaults
let {
title,
subtitle = 'Default subtitle',
count = 0,
onclick
}: Props = $props();
// Rest props
let { class: className, ...rest }: Props = $props();
</script>
<div class={className} {...rest}>
<h1>{title}</h1>
{#if subtitle}
<h2>{subtitle}</h2>
{/if}
</div>

Use modern event handler syntax:

<script lang="ts">
function handleClick(event: MouseEvent) {
console.log('Clicked', event);
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
console.log(target.value);
}
</script>
<!-- Modern syntax -->
<button onclick={handleClick}>Click</button>
<!-- Inline handler -->
<button onclick={() => console.log('Clicked')}>Click</button>
<!-- With event modifiers -->
<button onclick|preventDefault|stopPropagation={handleClick}>
Click
</button>
<input type="text" oninput={handleInput} />

Use two-way bindings appropriately:

<script lang="ts">
let value = $state('');
let checked = $state(false);
let selected = $state('');
let files = $state<FileList | null>(null);
let element = $state<HTMLDivElement>();
</script>
<!-- Input binding -->
<input type="text" bind:value />
<!-- Checkbox binding -->
<input type="checkbox" bind:checked />
<!-- Select binding -->
<select bind:value={selected}>
<option>A</option>
<option>B</option>
</select>
<!-- File binding -->
<input type="file" bind:files />
<!-- Element binding -->
<div bind:this={element}>Content</div>

Use {#if} blocks with proper structure:

<script lang="ts">
let status = $state<'loading' | 'success' | 'error'>('loading');
let user = $state<User | null>(null);
</script>
<!-- Simple condition -->
{#if user}
<p>Welcome, {user.name}!</p>
{/if}
<!-- If-else -->
{#if status === 'loading'}
<Spinner />
{:else if status === 'error'}
<Error />
{:else}
<Content />
{/if}
<!-- Await block -->
{#await fetchData()}
<Spinner />
{:then data}
<Content {data} />
{:catch error}
<Error {error} />
{/await}

Use {#each} with keys:

<script lang="ts">
let items = $state([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
]);
</script>
<!-- With key -->
{#each items as item (item.id)}
<div>{item.name}</div>
{/each}
<!-- With index -->
{#each items as item, i (item.id)}
<div>{i}: {item.name}</div>
{/each}
<!-- With else -->
{#each items as item (item.id)}
<div>{item.name}</div>
{:else}
<p>No items</p>
{/each}

Organize routes with clear structure:

src/routes/
├── +page.svelte # /
├── +layout.svelte # Root layout
├── +layout.ts # Layout load
├── about/
│ └── +page.svelte # /about
├── blog/
│ ├── +page.svelte # /blog
│ ├── +page.ts # Blog page load
│ └── [slug]/
│ ├── +page.svelte # /blog/:slug
│ └── +page.ts # Post page load
└── api/
└── posts/
└── +server.ts # /api/posts

Use typed load functions:

// +page.ts (universal load)
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, fetch }) => {
const response = await fetch(`/api/posts/${params.slug}`);
const post = await response.json();
return {
post
};
};
// +page.server.ts (server-only load)
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, locals }) => {
const post = await db.getPost(params.slug);
if (!post) {
throw error(404, 'Post not found');
}
return {
post,
user: locals.user
};
};

Implement progressive enhancement with actions:

+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async () => {
return {
posts: await db.getPosts()
};
};
export const actions: Actions = {
create: async ({ request }) => {
const data = await request.formData();
const title = data.get('title');
if (!title) {
return fail(400, {
error: 'Title is required',
title
});
}
const post = await db.createPost({ title });
throw redirect(303, `/blog/${post.slug}`);
},
delete: async ({ request }) => {
const data = await request.formData();
const id = data.get('id');
await db.deletePost(id);
return { success: true };
}
};
+page.svelte
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<form method="POST" action="?/create" use:enhance>
<input
type="text"
name="title"
value={form?.title ?? ''}
/>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<button type="submit">Create</button>
</form>
{#each data.posts as post}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={post.id} />
<button type="submit">Delete</button>
</form>
{/each}

Use stores for shared state:

lib/stores/counter.ts
import { writable, derived, readonly } from 'svelte/store';
// Writable store
export const count = writable(0);
// Derived store
export const doubled = derived(count, $count => $count * 2);
// Custom store
function createCounter() {
const { subscribe, set, update } = writable(0);
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0)
};
}
export const counter = createCounter();
<script lang="ts">
import { count, counter } from '$lib/stores/counter';
</script>
<!-- Auto-subscribe with $ -->
<p>Count: {$count}</p>
<p>Counter: {$counter}</p>
<button onclick={() => counter.increment()}>+</button>
<button onclick={() => counter.decrement()}>-</button>

Share data with component descendants:

Parent.svelte
<script lang="ts">
import { setContext } from 'svelte';
const theme = $state({ mode: 'dark' });
setContext('theme', theme);
</script>
<slot />
Child.svelte
<script lang="ts">
import { getContext } from 'svelte';
const theme = getContext<{ mode: string }>('theme');
</script>
<div class={theme.mode}>
Theme: {theme.mode}
</div>

Use snippets for template reuse:

<script lang="ts">
let items = $state([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
]);
</script>
{#snippet itemCard(item: Item)}
<div class="card">
<h3>{item.name}</h3>
<p>ID: {item.id}</p>
</div>
{/snippet}
{#each items as item (item.id)}
{@render itemCard(item)}
{/each}

Pass snippets as props:

Card.svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
children: Snippet;
actions?: Snippet;
}
let { title, children, actions }: Props = $props();
</script>
<div class="card">
<h2>{title}</h2>
<div class="content">
{@render children()}
</div>
{#if actions}
<div class="actions">
{@render actions()}
</div>
{/if}
</div>
<!-- Usage -->
<Card title="My Card">
{#snippet children()}
<p>Card content</p>
{/snippet}
{#snippet actions()}
<button>Save</button>
<button>Cancel</button>
{/snippet}
</Card>

Use scoped styles by default:

<div class="container">
<h1 class="title">Hello</h1>
</div>
<style>
.container {
padding: var(--spacing-md);
}
.title {
color: var(--color-primary);
font-size: 2rem;
}
/* Target slotted content */
:global(.slotted-class) {
margin: 1rem;
}
</style>

Use class directives for conditional classes:

<script lang="ts">
let isActive = $state(false);
let isPrimary = $state(true);
</script>
<!-- class: directive -->
<button
class:active={isActive}
class:primary={isPrimary}
>
Button
</button>
<!-- Shorthand when variable matches class name -->
<div class:loading>Content</div>

Use style directives for dynamic styles:

<script lang="ts">
let color = $state('#ff0000');
let size = $state(16);
</script>
<div
style:color
style:font-size="{size}px"
>
Styled content
</div>

Lazy load components when needed:

<script lang="ts">
let showHeavyComponent = $state(false);
</script>
<button onclick={() => showHeavyComponent = true}>
Load Component
</button>
{#if showHeavyComponent}
{#await import('./HeavyComponent.svelte')}
<p>Loading...</p>
{:then { default: HeavyComponent }}
<HeavyComponent />
{/await}
{/if}

Use virtual lists for large datasets:

<script lang="ts">
import { VirtualList } from 'svelte-virtual';
let items = $state(Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`
})));
</script>
<VirtualList items={items} let:item>
<div>{item.name}</div>
</VirtualList>

✅ Use runes ($state, $derived, $effect) in Svelte 5 ✅ Type all component props with TypeScript ✅ Use {#key} for proper reactivity ✅ Implement progressive enhancement with form actions ✅ Use snippets for template reuse ✅ Keep components small and focused ✅ Use stores for shared state ✅ Leverage SvelteKit’s load functions ✅ Use proper key in {#each} blocks ✅ Implement proper error handling

❌ Mutate props directly ❌ Use reactive statements ($:) in Svelte 5 (use runes) ❌ Create deeply nested component trees ❌ Fetch data in components (use load functions) ❌ Skip TypeScript types ❌ Use {#each} without keys ❌ Ignore accessibility ❌ Mix server and client logic incorrectly ❌ Create unnecessary reactivity

svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
$lib: 'src/lib',
$components: 'src/lib/components'
}
}
};
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true
}
}

Test components with Vitest and Testing Library:

import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import Button from './Button.svelte';
test('Button renders with text', () => {
render(Button, { props: { children: 'Click me' } });
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});