Skip to main content

Mastering Svelte: Patterns That Actually Matter

February 18, 2026

</>

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.css

Use 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

PracticeWhy
Use runes ($state, $derived)Explicit, predictable reactivity
$lib alias for importsClean, refactor-friendly paths
+page.server.ts for dataSecure, server-side data loading
Form actions for mutationsProgressive enhancement, no JS required
Snippets over slotsType-safe, composable templates
Context for component treesAvoid prop drilling
Stores for global stateReactive shared state
use:enhance on formsSPA-like form submissions
Small, focused componentsMaintainable and testable

Summary

Svelte best practices boil down to:

  1. Use runes$state, $derived, and $effect for clear reactivity
  2. Server-first data — load data in +page.server.ts, mutate with form actions
  3. Small components — compose from focused, reusable pieces
  4. Type everything — TypeScript with $props interface for safety
  5. Progressive enhancement — forms work without JavaScript by default

Recommended Posts