Svelte takes a radically different approach to building UIs — it compiles your code at build time, producing minimal JavaScript with no virtual DOM overhead. Here are the best practices to get the most out of Svelte and SvelteKit.
Project Structure
Organize with SvelteKit Conventions
src/
├── lib/
│ ├── components/ # Reusable components
│ │ ├── ui/ # Generic UI (Button, Modal, Input)
│ │ └── features/ # Feature-specific components
│ ├── server/ # Server-only utilities
│ │ ├── db.ts
│ │ └── auth.ts
│ ├── stores/ # Svelte stores
│ ├── utils/ # Shared utilities
│ └── types.ts # TypeScript types
├── routes/
│ ├── +layout.svelte
│ ├── +page.svelte
│ ├── blog/
│ │ ├── +page.server.ts
│ │ ├── +page.svelte
│ │ └── [slug]/
│ │ ├── +page.server.ts
│ │ └── +page.svelte
│ └── api/
│ └── users/+server.ts
├── app.html
└── app.cssUse the $lib Alias
Always import from $lib instead of relative paths:
// Bad
import Button from '../../../lib/components/ui/Button.svelte';
// Good
import Button from '$lib/components/ui/Button.svelte';Reactivity
Use Runes (Svelte 5+)
Svelte 5 introduced runes for explicit reactivity:
<script lang="ts">
let count = $state(0);
let double = $derived(count * 2);
function increment() {
count++;
}
</script>
<button onclick={increment}>
Count: {count} (Double: {double})
</button>Use $effect Sparingly
Effects are for side effects like DOM manipulation or logging, not for deriving state:
<script lang="ts">
let query = $state('');
let results = $state<string[]>([]);
// Bad - using effect to derive state
$effect(() => {
results = items.filter((item) => item.includes(query));
});
// Good - use $derived
let results = $derived(items.filter((item) => item.includes(query)));
// Good use of $effect - side effects
$effect(() => {
document.title = `Search: ${query}`;
});
</script>Deep Reactivity with $state
$state is deeply reactive for objects and arrays:
<script lang="ts">
let todos = $state([
{ id: 1, text: 'Learn Svelte', done: false },
{ id: 2, text: 'Build app', done: false },
]);
function toggle(id: number) {
const todo = todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done; // Reactive - no spread needed
}
function add(text: string) {
todos.push({ id: Date.now(), text, done: false }); // Reactive
}
</script>Components
Use Props with $props
<script lang="ts">
interface Props {
name: string;
age?: number;
ongreet?: (name: string) => void;
}
let { name, age = 25, ongreet }: Props = $props();
</script>
<div>
<p>{name} is {age} years old</p>
<button onclick={()=> ongreet?.(name)}>Greet</button>
</div>Use Snippets Instead of Slots (Svelte 5+)
<!-- Card.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
header: Snippet;
children: Snippet;
footer?: Snippet;
}
let { header, children, footer }: Props = $props();
</script>
<div class="card">
<div class="card-header">{@render header()}</div>
<div class="card-body">{@render children()}</div>
{#if footer}
<div class="card-footer">{@render footer()}</div>
{/if}
</div><!-- Usage -->
<Card>
{#snippet header()}
<h2>Title</h2>
{/snippet}
<p>Card content goes here</p>
{#snippet footer()}
<button>Save</button>
{/snippet}
</Card>Keep Components Small and Focused
<!-- Bad - one huge component -->
<script lang="ts">
// 200 lines of mixed logic for form, validation, API calls, and UI
</script>
<!-- Good - composed from smaller components -->
<script lang="ts">
import UserForm from './UserForm.svelte';
import UserPreview from './UserPreview.svelte';
import { createUser } from '$lib/api/users';
let formData = $state({ name: '', email: '' });
</script>
<UserForm bind:data={formData} />
<UserPreview data={formData} />
<button onclick={()=> createUser(formData)}>Submit</button>State Management
Use Svelte Stores for Shared State
// $lib/stores/auth.ts
import { writable, derived } from 'svelte/store';
interface User {
id: string;
name: string;
role: 'admin' | 'user';
}
export const user = writable<User | null>(null);
export const isAuthenticated = derived(user, ($user) => $user !== null);
export const isAdmin = derived(user, ($user) => $user?.role === 'admin');
Use Context for Component-Tree State
<!-- Parent.svelte -->
<script lang="ts">
import { setContext } from 'svelte';
const theme = $state({ mode: 'dark', accent: 'blue' });
setContext('theme', () => theme); // Pass getter for reactivity
</script>
<!-- Child.svelte (any depth) -->
<script lang="ts">
import { getContext } from 'svelte';
const getTheme = getContext<()=> { mode: string; accent: string }>('theme');
let theme = $derived(getTheme());
</script>
<div class={theme.mode}>Themed content</div>SvelteKit Data Loading
Use +page.server.ts for Server-Side Data
// routes/blog/+page.server.ts
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
export const load: PageServerLoad = async () => {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
});
return { posts };
};<!-- routes/blog/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
{#each data.posts as post}
<article>
<h2><a href="/blog/{post.slug}">{post.title}</a></h2>
</article>
{/each}Use Form Actions for Mutations
// routes/blog/new/+page.server.ts
import type { Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const title = formData.get('title') as string;
if (!title || title.length < 3) {
return fail(400, { title, error: 'Title must be at least 3 characters' });
}
const post = await db.post.create({ data: { title } });
throw redirect(303, `/blog/${post.slug}`);
},
};<!-- routes/blog/new/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<form method="POST" use:enhance>
<input name="title" value={form?.title ?? ''} />
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<button type="submit">Create Post</button>
</form>Performance
Avoid Unnecessary Reactivity
<script lang="ts">
// Bad - constant recalculated reactively
let TAX_RATE = $state(0.2); // Never changes
// Good - plain constant
const TAX_RATE = 0.2;
let price = $state(100);
let total = $derived(price * (1 + TAX_RATE));
</script>Use {#key} to Force Re-Rendering
{#key selectedUserId}
<UserProfile userId={selectedUserId} />
{/key}Lazy Load Components
<script lang="ts">
let showChart = $state(false);
</script>
<button onclick={()=> (showChart= true)}>Show Chart</button>
{#if showChart}
{#await import('$lib/components/HeavyChart.svelte') then { default: Chart }}
<Chart />
{/await}
{/if}
Error Handling
Use SvelteKit Error Pages
<!-- routes/+error.svelte -->
<script lang="ts">
import { page } from '$app/stores';
</script>
<h1>{$page.status}</h1>
<p>{$page.error?.message}</p>
<a href="/">Go home</a>Handle Errors in Load Functions
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) {
throw error(404, 'Post not found');
}
return { post };
};Security
Validate All Form Inputs Server-Side
// Always validate in +page.server.ts, never trust client
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export const actions: Actions = {
default: async ({ request }) => {
const formData = Object.fromEntries(await request.formData());
const result = schema.safeParse(formData);
if (!result.success) {
return fail(400, { errors: result.error.flatten().fieldErrors });
}
// Safe to use result.data
},
};Quick Reference
| Practice | Why |
|---|---|
Use runes ($state, $derived) | Explicit, predictable reactivity |
$lib alias for imports | Clean, refactor-friendly paths |
+page.server.ts for data | Secure, server-side data loading |
| Form actions for mutations | Progressive enhancement, no JS required |
| Snippets over slots | Type-safe, composable templates |
| Context for component trees | Avoid prop drilling |
| Stores for global state | Reactive shared state |
use:enhance on forms | SPA-like form submissions |
| Small, focused components | Maintainable and testable |
Summary
Svelte best practices boil down to:
- Use runes —
$state,$derived, and$effectfor clear reactivity - Server-first data — load data in
+page.server.ts, mutate with form actions - Small components — compose from focused, reusable pieces
- Type everything — TypeScript with
$propsinterface for safety - Progressive enhancement — forms work without JavaScript by default