Skip to content

Instantly share code, notes, and snippets.

@ospatil
Last active October 4, 2025 03:15
Show Gist options
  • Select an option

  • Save ospatil/2c14765c4c0b1cdc0ac167f2e48b23fd to your computer and use it in GitHub Desktop.

Select an option

Save ospatil/2c14765c4c0b1cdc0ac167f2e48b23fd to your computer and use it in GitHub Desktop.
Svelte 5 and sveltekit SSR and components Claude chats

Svelte 5 Components and State Management Guide

A comprehensive guide to component architecture and state management patterns in Svelte 5, focusing on the distinction between smart and presentational components.

Table of Contents

  1. Component Architecture Patterns
  2. Props and Callback Communication
  3. Context for Component Trees
  4. Shared State with Runes
  5. Derived State
  6. Effects and Side Effects
  7. Module Scripts
  8. Two-Way Binding with $bindable

Component Architecture Patterns

The distinction between smart and presentational components is a fundamental pattern in modern frontend development, and SvelteKit naturally supports this architecture.

Smart Components (Pages)

<!-- +page.svelte -->
<script>
  let { data } = $props();
  let selectedItems = $state([]);
  let filters = $state({ status: 'all' });
</script>

Presentational Components

<!-- DataTable.svelte -->
<script>
  let { items, onSelect, selectedItems } = $props();
</script>

State Management Strategies

1. Props Down, Events Up Pattern

<!-- Page passes data down, receives events up -->
<DataTable
  {items}
  {selectedItems}
  onselect={(item) => selectedItems.push(item)}
/>

2. Shared State with Runes

For complex state that needs to persist across components or pages:

// stores.svelte.js
export const appState = $state({
  selectedItems: [],
  filters: {}
});

3. Context for Component Trees

<!-- Parent component -->
<script>
  import { setContext } from 'svelte';
  setContext('tableState', { selectedItems, filters });
</script>

The key is keeping your presentational components truly "presentational" - they should only receive props and emit events, never directly mutate external state. This keeps them reusable and testable while your page components orchestrate the data flow and business logic.


Props and Callback Communication

A detailed example demonstrating the complete data flow between smart and presentational components.

Smart Component Example

<script>
  import DataTable from '$lib/components/DataTable.svelte';
  import { onMount } from 'svelte';

  let { data } = $props(); // Initial SSR data

  // Local state management
  let users = $state(data.users || []);
  let selectedUsers = $state([]);
  let loading = $state(false);
  let sortConfig = $state({ field: 'name', direction: 'asc' });

  // Fetch fresh data
  async function fetchUsers() {
    loading = true;
    try {
      const response = await fetch('/api/users');
      users = await response.json();
    } finally {
      loading = false;
    }
  }

  // Handle events from DataTable
  function handleUserSelect(event) {
    const { user, selected } = event.detail;
    if (selected) {
      selectedUsers.push(user);
    } else {
      selectedUsers = selectedUsers.filter(u => u.id !== user.id);
    }
  }

  function handleSort(event) {
    const { field, direction } = event.detail;
    sortConfig = { field, direction };

    // Sort the data
    users = users.toSorted((a, b) => {
      const aVal = a[field];
      const bVal = b[field];
      const mult = direction === 'asc' ? 1 : -1;
      return aVal < bVal ? -mult : aVal > bVal ? mult : 0;
    });
  }

  function handleDelete(event) {
    const { userId } = event.detail;
    users = users.filter(u => u.id !== userId);
    selectedUsers = selectedUsers.filter(u => u.id !== userId);
  }
</script>

<div class="page">
  <h1>Users ({users.length})</h1>

  {#if selectedUsers.length > 0}
    <div class="selection-info">
      {selectedUsers.length} users selected
    </div>
  {/if}

  <DataTable
    items={users}
    {selectedUsers}
    {sortConfig}
    {loading}
    onselect={handleUserSelect}
    onsort={handleSort}
    ondelete={handleDelete}
  />
</div>

Presentational Component Example

<script>
  import { createEventDispatcher } from 'svelte';

  let {
    items = [],
    selectedUsers = [],
    sortConfig = { field: 'name', direction: 'asc' },
    loading = false
  } = $props();

  const dispatch = createEventDispatcher();

  function toggleSelect(user) {
    const isSelected = selectedUsers.some(u => u.id === user.id);
    dispatch('select', {
      user,
      selected: !isSelected
    });
  }

  function handleSort(field) {
    const direction = sortConfig.field === field && sortConfig.direction === 'asc'
      ? 'desc' : 'asc';
    dispatch('sort', { field, direction });
  }

  function deleteUser(userId) {
    dispatch('delete', { userId });
  }

  function isSelected(user) {
    return selectedUsers.some(u => u.id === user.id);
  }
</script>

<div class="datatable">
  {#if loading}
    <div class="loading">Loading...</div>
  {:else}
    <table>
      <thead>
        <tr>
          <th>
            <input type="checkbox" />
          </th>
          <th>
            <button onclick={() => handleSort('name')}>
              Name
              {#if sortConfig.field === 'name'}
                {sortConfig.direction === 'asc' ? '' : ''}
              {/if}
            </button>
          </th>
          <th>
            <button onclick={() => handleSort('email')}>
              Email
              {#if sortConfig.field === 'email'}
                {sortConfig.direction === 'asc' ? '' : ''}
              {/if}
            </button>
          </th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        {#each items as user (user.id)}
          <tr class:selected={isSelected(user)}>
            <td>
              <input
                type="checkbox"
                checked={isSelected(user)}
                onchange={() => toggleSelect(user)}
              />
            </td>
            <td>{user.name}</td>
            <td>{user.email}</td>
            <td>
              <button onclick={() => deleteUser(user.id)}>
                Delete
              </button>
            </td>
          </tr>
        {/each}
      </tbody>
    </table>
  {/if}
</div>

Key Architecture Principles

  • The page component owns all the state (users, selectedUsers, sortConfig)
  • DataTable only receives props and dispatches events - it never mutates external state directly
  • Events flow up with meaningful data (event.detail)
  • The page component handles all business logic (sorting, filtering, API calls)
  • DataTable remains completely reusable across different contexts

This pattern keeps your components decoupled while maintaining clear data flow.

Svelte 5 Callback Props Pattern

In Svelte 5, createEventDispatcher is deprecated in favor of callback props - functions passed as properties to components.

Updated Smart Component

<script>
  import DataTable from '$lib/components/DataTable.svelte';
  import { onMount } from 'svelte';

  let { data } = $props(); // Initial SSR data

  // Local state management
  let users = $state(data.users || []);
  let selectedUsers = $state([]);
  let loading = $state(false);
  let sortConfig = $state({ field: 'name', direction: 'asc' });

  // Fetch fresh data
  async function fetchUsers() {
    loading = true;
    try {
      const response = await fetch('/api/users');
      users = await response.json();
    } finally {
      loading = false;
    }
  }

  // Callback functions passed as props
  function handleUserSelect(user, selected) {
    if (selected) {
      selectedUsers.push(user);
    } else {
      selectedUsers = selectedUsers.filter(u => u.id !== user.id);
    }
  }

  function handleSort(field, direction) {
    sortConfig = { field, direction };

    // Sort the data
    users = users.toSorted((a, b) => {
      const aVal = a[field];
      const bVal = b[field];
      const mult = direction === 'asc' ? 1 : -1;
      return aVal < bVal ? -mult : aVal > bVal ? mult : 0;
    });
  }

  function handleDelete(userId) {
    users = users.filter(u => u.id !== userId);
    selectedUsers = selectedUsers.filter(u => u.id !== userId);
  }
</script>

<div class="page">
  <h1>Users ({users.length})</h1>

  {#if selectedUsers.length > 0}
    <div class="selection-info">
      {selectedUsers.length} users selected
    </div>
  {/if}

  <DataTable
    items={users}
    {selectedUsers}
    {sortConfig}
    {loading}
    onselect={handleUserSelect}
    onsort={handleSort}
    ondelete={handleDelete}
  />
</div>

Updated Presentational Component

<script>
  let {
    items = [],
    selectedUsers = [],
    sortConfig = { field: 'name', direction: 'asc' },
    loading = false,
    onselect = () => {},
    onsort = () => {},
    ondelete = () => {}
  } = $props();

  function toggleSelect(user) {
    const isSelected = selectedUsers.some(u => u.id === user.id);
    onselect(user, !isSelected);
  }

  function handleSort(field) {
    const direction = sortConfig.field === field && sortConfig.direction === 'asc'
      ? 'desc' : 'asc';
    onsort(field, direction);
  }

  function deleteUser(userId) {
    ondelete(userId);
  }

  function isSelected(user) {
    return selectedUsers.some(u => u.id === user.id);
  }
</script>

<div class="datatable">
  {#if loading}
    <div class="loading">Loading...</div>
  {:else}
    <table>
      <thead>
        <tr>
          <th>
            <input type="checkbox" />
          </th>
          <th>
            <button onclick={() => handleSort('name')}>
              Name
              {#if sortConfig.field === 'name'}
                {sortConfig.direction === 'asc' ? '' : ''}
              {/if}
            </button>
          </th>
          <th>
            <button onclick={() => handleSort('email')}>
              Email
              {#if sortConfig.field === 'email'}
                {sortConfig.direction === 'asc' ? '' : ''}
              {/if}
            </button>
          </th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody>
        {#each items as user (user.id)}
          <tr class:selected={isSelected(user)}>
            <td>
              <input
                type="checkbox"
                checked={isSelected(user)}
                onchange={() => toggleSelect(user)}
              />
            </td>
            <td>{user.name}</td>
            <td>{user.email}</td>
            <td>
              <button onclick={() => deleteUser(user.id)}>
                Delete
              </button>
            </td>
          </tr>
        {/each}
      </tbody>
    </table>
  {/if}
</div>

Key Changes in Svelte 5

  • No more createEventDispatcher() import
  • Callback props are defined with default empty functions: onselect = () => {}
  • Direct function calls instead of dispatch(): onselect(user, !isSelected)
  • Cleaner parameter passing - no need for event.detail wrapper objects
  • More TypeScript-friendly as callback signatures are explicit

This approach is much more straightforward and aligns better with how React and other frameworks handle component communication!


Context for Component Trees

Context is perfect for sharing state that multiple components in a tree need access to, without prop drilling. Here's a practical example with a theme system.

Theme Context Setup

import { getContext, setContext } from 'svelte';

const THEME_KEY = 'theme';

export function createThemeContext() {
  let theme = $state('light');
  let preferences = $state({
    fontSize: 'medium',
    animations: true
  });

  const context = {
    get theme() { return theme; },
    get preferences() { return preferences; },

    toggleTheme() {
      theme = theme === 'light' ? 'dark' : 'light';
    },

    updatePreferences(newPrefs) {
      preferences = { ...preferences, ...newPrefs };
    }
  };

  setContext(THEME_KEY, context);
  return context;
}

export function getThemeContext() {
  return getContext(THEME_KEY);
}

Root Layout Implementation

<script>
  import { createThemeContext } from '$lib/contexts/themeContext.svelte.js';
  import Header from '$lib/components/Header.svelte';
  import Sidebar from '$lib/components/Sidebar.svelte';

  // Create and provide context at the top level
  const themeContext = createThemeContext();
</script>

<div class="app" class:dark={themeContext.theme === 'dark'}>
  <Header />
  <main>
    <Sidebar />
    <div class="content">
      <slot />
    </div>
  </main>
</div>

Header Component

<script>
  import { getThemeContext } from '$lib/contexts/themeContext.svelte.js';
  import ThemeToggle from './ThemeToggle.svelte';

  const { theme } = getThemeContext();
</script>

<header class="header" class:dark={theme === 'dark'}>
  <h1>My App</h1>
  <ThemeToggle />
</header>

Theme Toggle Component

<script>
  import { getThemeContext } from '$lib/contexts/themeContext.svelte.js';

  const { theme, toggleTheme } = getThemeContext();
</script>

<button
  onclick={toggleTheme}
  class="theme-toggle"
  aria-label="Toggle theme"
>
  {theme === 'light' ? '🌙' : '☀️'}
</button>

Settings Page

<script>
  import { getThemeContext } from '$lib/contexts/themeContext.svelte.js';

  const { preferences, updatePreferences } = getThemeContext();

  function handleFontSizeChange(size) {
    updatePreferences({ fontSize: size });
  }
</script>

<div class="settings">
  <h2>Preferences</h2>

  <div class="setting">
    <label>Font Size:</label>
    <select
      value={preferences.fontSize}
      onchange={(e) => handleFontSizeChange(e.target.value)}
    >
      <option value="small">Small</option>
      <option value="medium">Medium</option>
      <option value="large">Large</option>
    </select>
  </div>

  <div class="setting">
    <label>
      <input
        type="checkbox"
        checked={preferences.animations}
        onchange={(e) => updatePreferences({ animations: e.target.checked })}
      />
      Enable animations
    </label>
  </div>
</div>

When to Use Context

  • Theme/UI state (like above) - many components need access
  • User authentication - profile info, permissions across components
  • Shopping cart - product lists, cart icon, checkout flow
  • Form state - complex multi-step forms with shared validation
  • Modal/dialog management - any component should be able to open modals

When NOT to Use Context

  • Simple parent-child communication (use props)
  • Data that only 1-2 components need
  • Frequently changing data that would cause many re-renders

Context shines when you have state that logically belongs "above" your component tree and multiple descendants need access to it. It eliminates the prop drilling problem while keeping components decoupled.


Shared State with Runes

Svelte 5 provides multiple approaches for shared state management using runes.

Simple Object-based Store

// Simple object-based store
export const cartStore = $state({
  items: [],
  total: 0
});

export const cartActions = {
  addItem(product) {
    const existingItem = cartStore.items.find(item => item.id === product.id);
    if (existingItem) {
      existingItem.quantity += 1;
    } else {
      cartStore.items.push({ ...product, quantity: 1 });
    }
    this.updateTotal();
  },

  removeItem(productId) {
    cartStore.items = cartStore.items.filter(item => item.id !== productId);
    this.updateTotal();
  },

  updateTotal() {
    cartStore.total = cartStore.items.reduce((sum, item) =>
      sum + (item.price * item.quantity), 0
    );
  }
};

Class-based Store with Reactive Properties

// stores.svelte.js
class CartStore {
  items = $state([]);
  total = $state.derived(() =>
    this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
  );
  isLoading = $state(false);

  addItem(product) {
    const existingItem = this.items.find(item => item.id === product.id);
    if (existingItem) {
      existingItem.quantity += 1;
    } else {
      this.items.push({ ...product, quantity: 1 });
    }
  }

  removeItem(productId) {
    this.items = this.items.filter(item => item.id !== productId);
  }

  async loadCart(userId) {
    this.isLoading = true;
    try {
      const response = await fetch(`/api/cart/${userId}`);
      this.items = await response.json();
    } finally {
      this.isLoading = false;
    }
  }

  get itemCount() {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }

  clear() {
    this.items = [];
  }
}

// Export singleton instance
export const cartStore = new CartStore();

Using Class-based Stores in Components

Product List Page

<script>
  import { cartStore } from '$lib/stores.svelte.js';

  let { data } = $props();
  let products = data.products;

  function addToCart(product) {
    cartStore.addItem(product);
  }
</script>

<div class="products">
  {#each products as product}
    <div class="product-card">
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onclick={() => addToCart(product)}>
        Add to Cart
      </button>
    </div>
  {/each}
</div>

Cart Component

<script>
  import { cartStore } from '$lib/stores.svelte.js';
</script>

<div class="cart-icon">
  🛒
  {#if cartStore.itemCount > 0}
    <span class="badge">{cartStore.itemCount}</span>
  {/if}
  <span class="total">${cartStore.total.toFixed(2)}</span>
</div>

Cart Page

<script>
  import { cartStore } from '$lib/stores.svelte.js';
  import { onMount } from 'svelte';

  onMount(() => {
    // Load cart data if needed
    cartStore.loadCart('user123');
  });
</script>

<div class="cart-page">
  <h1>Shopping Cart</h1>

  {#if cartStore.isLoading}
    <p>Loading cart...</p>
  {:else if cartStore.items.length === 0}
    <p>Your cart is empty</p>
  {:else}
    {#each cartStore.items as item}
      <div class="cart-item">
        <span>{item.name}</span>
        <span>Qty: {item.quantity}</span>
        <span>${(item.price * item.quantity).toFixed(2)}</span>
        <button onclick={() => cartStore.removeItem(item.id)}>
          Remove
        </button>
      </div>
    {/each}

    <div class="cart-total">
      <strong>Total: ${cartStore.total.toFixed(2)}</strong>
    </div>

    <button onclick={() => cartStore.clear()}>
      Clear Cart
    </button>
  {/if}
</div>

Advanced Class Store Example

class AppStore {
  // User state
  user = $state(null);
  isAuthenticated = $state.derived(() => !!this.user);

  // UI state
  sidebarOpen = $state(false);
  theme = $state('light');

  // Notifications
  notifications = $state([]);

  // Actions
  login(userData) {
    this.user = userData;
  }

  logout() {
    this.user = null;
  }

  toggleSidebar() {
    this.sidebarOpen = !this.sidebarOpen;
  }

  addNotification(message, type = 'info') {
    const id = Date.now();
    this.notifications.push({ id, message, type });

    // Auto remove after 5 seconds
    setTimeout(() => {
      this.notifications = this.notifications.filter(n => n.id !== id);
    }, 5000);
  }
}

export const appStore = new AppStore();

Benefits of Class-based Stores

  • Encapsulation of related state and methods
  • Built-in reactivity with $state and $state.derived
  • TypeScript-friendly
  • Can use getters for computed values
  • Easy to organize complex state logic

When to Use Each Approach

  • Simple object + actions: For straightforward stores with basic operations
  • Class-based: When you have complex state logic, multiple related pieces of state, or want better organization and type safety

Both approaches work great with SvelteKit's SSR and maintain reactivity across your entire application!


Derived State

$state.derived is perfect for computed values that depend on reactive state. Here are practical examples showing its power.

Basic Derived State

// stores.svelte.js
export const userStore = $state({
  firstName: 'John',
  lastName: 'Doe',
  email: 'john@example.com'
});

// Derived values automatically update when dependencies change
export const fullName = $state.derived(() =>
  `${userStore.firstName} ${userStore.lastName}`
);

export const emailDomain = $state.derived(() =>
  userStore.email.split('@')[1] || ''
);

Shopping Cart with Complex Derived Values

class ShoppingCart {
  items = $state([]);
  taxRate = $state(0.08);
  discountCode = $state(null);

  // Derived subtotal
  subtotal = $state.derived(() =>
    this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
  );

  // Derived discount amount
  discountAmount = $state.derived(() => {
    if (!this.discountCode) return 0;

    switch (this.discountCode.type) {
      case 'percentage':
        return this.subtotal * (this.discountCode.value / 100);
      case 'fixed':
        return Math.min(this.discountCode.value, this.subtotal);
      default:
        return 0;
    }
  });

  // Derived tax amount
  taxAmount = $state.derived(() =>
    (this.subtotal - this.discountAmount) * this.taxRate
  );

  // Final total (depends on multiple derived values)
  total = $state.derived(() =>
    this.subtotal - this.discountAmount + this.taxAmount
  );

  // Derived item count
  itemCount = $state.derived(() =>
    this.items.reduce((sum, item) => sum + item.quantity, 0)
  );

  // Derived shipping cost based on total
  shippingCost = $state.derived(() => {
    if (this.subtotal >= 50) return 0; // Free shipping over $50
    if (this.subtotal >= 25) return 5; // $5 shipping
    return 10; // $10 shipping
  });
}

export const cart = new ShoppingCart();

Search and Filter Example

// stores.svelte.js
export const productsStore = $state({
  products: [],
  searchQuery: '',
  selectedCategory: 'all',
  priceRange: { min: 0, max: 1000 },
  sortBy: 'name'
});

// Filtered and sorted products
export const filteredProducts = $state.derived(() => {
  let filtered = productsStore.products;

  // Filter by search query
  if (productsStore.searchQuery) {
    const query = productsStore.searchQuery.toLowerCase();
    filtered = filtered.filter(product =>
      product.name.toLowerCase().includes(query) ||
      product.description.toLowerCase().includes(query)
    );
  }

  // Filter by category
  if (productsStore.selectedCategory !== 'all') {
    filtered = filtered.filter(product =>
      product.category === productsStore.selectedCategory
    );
  }

  // Filter by price range
  filtered = filtered.filter(product =>
    product.price >= productsStore.priceRange.min &&
    product.price <= productsStore.priceRange.max
  );

  // Sort results
  return filtered.toSorted((a, b) => {
    switch (productsStore.sortBy) {
      case 'name':
        return a.name.localeCompare(b.name);
      case 'price-low':
        return a.price - b.price;
      case 'price-high':
        return b.price - a.price;
      case 'rating':
        return b.rating - a.rating;
      default:
        return 0;
    }
  });
});

// Derived search stats
export const searchStats = $state.derived(() => ({
  totalResults: filteredProducts.length,
  hasResults: filteredProducts.length > 0,
  isSearching: productsStore.searchQuery.length > 0,
  categories: [...new Set(filteredProducts.map(p => p.category))]
}));

Using Derived State in Components

<script>
  import { cart } from '$lib/stores/cart.svelte.js';
  import { productsStore, filteredProducts, searchStats } from '$lib/stores/products.svelte.js';
</script>

<!-- Cart summary - automatically updates when cart changes -->
<div class="cart-summary">
  <p>Items: {cart.itemCount}</p>
  <p>Subtotal: ${cart.subtotal.toFixed(2)}</p>
  {#if cart.discountAmount > 0}
    <p>Discount: -${cart.discountAmount.toFixed(2)}</p>
  {/if}
  <p>Tax: ${cart.taxAmount.toFixed(2)}</p>
  <p>Shipping: ${cart.shippingCost.toFixed(2)}</p>
  <strong>Total: ${cart.total.toFixed(2)}</strong>
</div>

<!-- Product search - results update automatically -->
<div class="product-search">
  <input
    bind:value={productsStore.searchQuery}
    placeholder="Search products..."
  />

  <select bind:value={productsStore.selectedCategory}>
    <option value="all">All Categories</option>
    {#each searchStats.categories as category}
      <option value={category}>{category}</option>
    {/each}
  </select>

  <p>{searchStats.totalResults} results found</p>

  {#each filteredProducts as product}
    <div class="product">{product.name} - ${product.price}</div>
  {/each}
</div>

Advanced: Derived with Async Data

export const apiStore = $state({
  userId: null,
  userCache: new Map()
});

// Derived user data that handles caching and loading
export const currentUser = $state.derived(async () => {
  if (!apiStore.userId) return null;

  // Check cache first
  if (apiStore.userCache.has(apiStore.userId)) {
    return apiStore.userCache.get(apiStore.userId);
  }

  // Fetch from API
  const response = await fetch(`/api/users/${apiStore.userId}`);
  const user = await response.json();

  // Cache the result
  apiStore.userCache.set(apiStore.userId, user);
  return user;
});

Key Benefits of $state.derived

  • Automatic updates: Recalculates when dependencies change
  • Memoization: Only recalculates when dependencies actually change
  • Clean dependencies: Svelte tracks what your derived state depends on
  • Performance: Avoids unnecessary computations
  • Composability: Derived values can depend on other derived values

Derived state is perfect for any computed values, filtered lists, formatted data, or complex calculations based on your reactive state!


Effects and Side Effects

$effect should be used sparingly and only when necessary. Here are the best practices and legitimate use cases.

Good Use Cases for $effect

1. Side Effects with External APIs

let userId = $state(null);
let userData = $state(null);

$effect(() => {
  if (!userId) return;

  // Side effect: sync with external system
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => userData = data)
    .catch(err => {
      if (err.name !== 'AbortError') {
        console.error('Failed to fetch user:', err);
      }
    });

  // Cleanup function
  return () => controller.abort();
});

2. DOM Manipulation (when necessary)

let chartContainer = $state();
let chartData = $state([]);
let chartInstance = null;

$effect(() => {
  if (!chartContainer || !chartData.length) return;

  // Clean up previous chart
  if (chartInstance) {
    chartInstance.destroy();
  }

  // Create new chart with external library
  chartInstance = new Chart(chartContainer, {
    type: 'line',
    data: chartData
  });

  return () => {
    if (chartInstance) {
      chartInstance.destroy();
      chartInstance = null;
    }
  };
});

3. Browser API Synchronization

let theme = $state('light');

$effect(() => {
  // Sync with localStorage
  localStorage.setItem('theme', theme);

  // Update CSS custom property
  document.documentElement.setAttribute('data-theme', theme);
});

let windowWidth = $state(0);

$effect(() => {
  if (typeof window === 'undefined') return;

  function updateWidth() {
    windowWidth = window.innerWidth;
  }

  updateWidth();
  window.addEventListener('resize', updateWidth);

  return () => window.removeEventListener('resize', updateWidth);
});

4. Analytics and Logging

let currentPage = $state('/');
let user = $state(null);

$effect(() => {
  if (!user || !currentPage) return;

  // Track page views
  analytics.track('page_view', {
    userId: user.id,
    page: currentPage,
    timestamp: Date.now()
  });
});

Anti-patterns to Avoid

❌ Don't use effects for derived state

// BAD
let firstName = $state('John');
let lastName = $state('Doe');
let fullName = $state('');

$effect(() => {
  fullName = `${firstName} ${lastName}`; // Use $state.derived instead!
});

// GOOD
let firstName = $state('John');
let lastName = $state('Doe');
let fullName = $state.derived(() => `${firstName} ${lastName}`);

❌ Don't use effects for component communication

// BAD
let selectedItem = $state(null);

$effect(() => {
  if (selectedItem) {
    // Don't use effects to trigger other state changes
    showModal = true;
    loadItemDetails(selectedItem.id);
  }
});

// GOOD - Use functions/callbacks instead
function handleItemSelect(item) {
  selectedItem = item;
  showModal = true;
  loadItemDetails(item.id);
}

Best Practices

1. Always Clean Up

$effect(() => {
  const interval = setInterval(() => {
    // Do something
  }, 1000);

  // Always return cleanup function
  return () => clearInterval(interval);
});

2. Guard Against Invalid State

$effect(() => {
  // Guard clauses first
  if (!isClient || !userId || !isAuthenticated) return;

  // Effect logic here
  syncUserPreferences();
});

3. Use $effect.pre for DOM Measurements

let element = $state();
let elementHeight = $state(0);

// Runs before DOM updates
$effect.pre(() => {
  if (element) {
    elementHeight = element.offsetHeight;
  }
});

4. Prefer $effect.root for Global Effects

// In a store or root component
export function createGlobalEffects() {
  return $effect.root(() => {
    // Global keyboard shortcuts
    $effect(() => {
      function handleKeyboard(e) {
        if (e.ctrlKey && e.key === 'k') {
          openCommandPalette();
        }
      }

      document.addEventListener('keydown', handleKeyboard);
      return () => document.removeEventListener('keydown', handleKeyboard);
    });

    // Global theme sync
    $effect(() => {
      document.body.className = `theme-${currentTheme}`;
    });
  });
}

5. Debugging Effects

$effect(() => {
  // Debug what triggered the effect
  console.log('Effect triggered:', { userId, preferences, theme });

  // Effect logic
  syncUserSettings();
});

When NOT to Use Effects

  • Computing derived values (use $state.derived)
  • Handling user interactions (use event handlers)
  • Component-to-component communication (use props/callbacks)
  • Simple state updates (use functions)
  • Form validation (use derived state)

Summary

Use $effect only for:

  • External API calls/sync
  • Browser API integration
  • DOM manipulation with third-party libraries
  • Logging/analytics
  • Resource cleanup

The key is: if you can achieve the same result with derived state, props, or regular functions, prefer those approaches!

Component Lifecycle and Effects

Understanding when effects execute in the component lifecycle is crucial for proper state management.

Svelte 5 Component Lifecycle

<script>
  import { onMount, beforeUpdate, afterUpdate, onDestroy } from 'svelte';

  let count = $state(0);

  // 1. Script runs - component creation
  console.log('1. Script execution');

  // 2. Effects are scheduled but not run yet
  $effect(() => {
    console.log('4. Effect runs after DOM update');
  });

  $effect.pre(() => {
    console.log('3. Pre-effect runs before DOM update');
  });

  // 3. Lifecycle hooks are registered
  onMount(() => {
    console.log('5. onMount - component mounted to DOM');
  });

  beforeUpdate(() => {
    console.log('Before update (on subsequent updates)');
  });

  afterUpdate(() => {
    console.log('After update (on subsequent updates)');
  });

  onDestroy(() => {
    console.log('Component destroyed');
  });
</script>

<!-- 2. Template is processed -->
<button onclick={() => count++}>
  {count}
</button>

Detailed Execution Order

Initial Mount
  1. Script Execution - Variables declared, effects registered
  2. Template Processing - DOM nodes created but not inserted
  3. $effect.pre - Runs before DOM insertion (for measuring existing DOM)
  4. DOM Update - Component inserted into DOM
  5. $effect - Runs after DOM is updated
  6. onMount - Runs after component is fully mounted
Subsequent Updates
  1. beforeUpdate - Before any DOM changes
  2. $effect.pre - Before DOM update (can read old DOM state)
  3. DOM Update - Changes applied to DOM
  4. $effect - After DOM is updated (can read new DOM state)
  5. afterUpdate - After all updates complete

Effect Timing Examples

let element;
let width = $state(0);

// Runs BEFORE DOM updates - good for measuring current state
$effect.pre(() => {
  if (element) {
    console.log('Old width:', element.offsetWidth);
  }
});

// Runs AFTER DOM updates - good for reading new state
$effect(() => {
  if (element) {
    width = element.offsetWidth;
    console.log('New width:', width);
  }
});

// Traditional lifecycle hooks
onMount(() => {
  // Component is fully rendered and in DOM
  // Good for: initializing third-party libraries, focus management
  console.log('Component mounted');
});

beforeUpdate(() => {
  // Before any reactive updates
  // Good for: capturing scroll position before updates
});

afterUpdate(() => {
  // After all reactive updates complete
  // Good for: operations that need updated DOM
});

Practical Example - Scroll Position Restoration

<script>
  let scrollContainer;
  let items = $state([]);
  let savedScrollTop = 0;

  // Save scroll position before updates
  beforeUpdate(() => {
    if (scrollContainer) {
      savedScrollTop = scrollContainer.scrollTop;
    }
  });

  // Restore scroll position after DOM updates
  $effect(() => {
    if (scrollContainer && items.length > 0) {
      scrollContainer.scrollTop = savedScrollTop;
    }
  });

  onMount(() => {
    // Initialize with data
    loadItems();
  });
</script>

<div bind:this={scrollContainer} class="scroll-container">
  {#each items as item}
    <div>{item.name}</div>
  {/each}
</div>

Key Differences from Svelte 4

  • Effects replace reactive statements ($:)
  • More predictable timing - effects always run after DOM updates
  • Better cleanup - effects can return cleanup functions
  • Pre-effects - new timing option for before DOM updates

Best Practices for Effect Timing

  • Use $effect.pre when you need to read DOM state before changes
  • Use $effect when you need to react to DOM changes or sync with external systems
  • Use onMount for one-time initialization
  • Use beforeUpdate/afterUpdate sparingly - effects usually handle these cases better

The key insight is that effects are part of Svelte's reactive system and run as part of the update cycle, while lifecycle hooks are more about component state transitions.

One correction - Svelte 4 contained hooks that ran before and after the component as a whole was updated. For backwards compatibility, these hooks were shimmed in Svelte 5 but not available inside components that use runes. Instead of beforeUpdate use $effect.pre and instead of afterUpdate use $effect instead - these runes offer more granular control and only react to the changes you’re actually interested in. Update the lifecycle example to account for this change.

Svelte 5 Lifecycle Updates

In Svelte 5, the traditional beforeUpdate and afterUpdate hooks are replaced with more granular rune-based effects when using runes:

Updated Component Lifecycle Example

<script>
  import { onMount, onDestroy } from 'svelte';

  let count = $state(0);
  let element;

  // 1. Script runs - component creation
  console.log('1. Script execution');

  // 2. Effects are scheduled but not run yet
  $effect.pre(() => {
    console.log('3. Pre-effect runs before DOM update');
    // Replaces beforeUpdate - more granular, only runs when dependencies change
    if (element) {
      console.log('Before update - current count in DOM:', element.textContent);
    }
  });

  $effect(() => {
    console.log('4. Effect runs after DOM update');
    // Replaces afterUpdate - only reacts to specific state changes
    if (element) {
      console.log('After update - new count in DOM:', element.textContent);
    }
  });

  // 3. Lifecycle hooks are registered
  onMount(() => {
    console.log('5. onMount - component mounted to DOM');
  });

  onDestroy(() => {
    console.log('Component destroyed');
  });
</script>

<!-- 2. Template is processed -->
<button onclick={() => count++}>
  <span bind:this={element}>{count}</span>
</button>

Key Changes in Svelte 5

❌ Old Svelte 4 way
<script>
  import { beforeUpdate, afterUpdate } from 'svelte';

  // Runs before EVERY component update (not granular)
  beforeUpdate(() => {
    console.log('Before any update');
  });

  // Runs after EVERY component update (not granular)
  afterUpdate(() => {
    console.log('After any update');
  });
</script>
✅ New Svelte 5 way
<script>
  let count = $state(0);
  let name = $state('');
  let element;

  // Only runs when count changes (granular!)
  $effect.pre(() => {
    console.log('Before count update:', count);
  });

  $effect(() => {
    console.log('After count update:', count);
    // Can access updated DOM here
  });

  // Separate effect for name changes
  $effect(() => {
    console.log('Name changed to:', name);
    // This won't run when count changes
  });
</script>

Practical Migration Example

Svelte 4 approach
<script>
  import { beforeUpdate, afterUpdate } from 'svelte';

  let scrollContainer;
  let items = [];
  let savedScrollTop = 0;

  // Runs before ANY update to the component
  beforeUpdate(() => {
    if (scrollContainer) {
      savedScrollTop = scrollContainer.scrollTop;
    }
  });

  // Runs after ANY update to the component
  afterUpdate(() => {
    if (scrollContainer) {
      scrollContainer.scrollTop = savedScrollTop;
    }
  });
</script>
Svelte 5 approach (more efficient)
<script>
  let scrollContainer;
  let items = $state([]);
  let savedScrollTop = 0;

  // Only runs when items change (not other unrelated state)
  $effect.pre(() => {
    if (scrollContainer && items) {
      savedScrollTop = scrollContainer.scrollTop;
    }
  });

  $effect(() => {
    if (scrollContainer && items.length > 0) {
      scrollContainer.scrollTop = savedScrollTop;
    }
  });
</script>

Execution Order (Svelte 5 with Runes)

Initial Mount
  1. Script Execution - State and effects registered
  2. Template Processing - DOM structure created
  3. $effect.pre - Runs before DOM insertion (if dependencies exist)
  4. DOM Update - Component inserted/updated
  5. $effect - Runs after DOM changes
  6. onMount - Component fully mounted
Subsequent Updates
  1. State Change - Reactive state updated
  2. $effect.pre - Before DOM reflects the change (only for relevant effects)
  3. DOM Update - Changes applied
  4. $effect - After DOM updated (only for relevant effects)

Benefits of the New Approach

  • Granular reactivity - Effects only run when their dependencies change
  • Better performance - No unnecessary effect runs
  • Clearer dependencies - Svelte tracks exactly what each effect depends on
  • Easier debugging - Effects are tied to specific state changes

The key insight is that $effect.pre and $effect replace the broad beforeUpdate/afterUpdate hooks with fine-grained reactive effects that only run when needed!


Module Scripts

A "module" in this context refers to the single .svelte file itself. The <script module> block runs once when the .svelte file is first imported/loaded by the JavaScript module system, not once per component instance.

Regular <script> vs <script module>

<!-- MyComponent.svelte -->
<script module>
  // Runs ONCE when MyComponent.svelte is first imported
  console.log('Module script runs once');

  let moduleCounter = 0;

  // This is shared across ALL instances of MyComponent
  export function getNextId() {
    return ++moduleCounter;
  }

  // Module-level constants
  export const COMPONENT_NAME = 'MyComponent';
  export const DEFAULT_CONFIG = { theme: 'light', size: 'medium' };
</script>

<script>
  // Runs for EACH component instance
  console.log('Instance script runs per component');

  let instanceId = getNextId(); // Each instance gets unique ID
  let count = $state(0);

  // Can access module variables
  console.log('Component name:', COMPONENT_NAME);
</script>

<div>
  Instance #{instanceId}: {count}
  <button onclick={() => count++}>+</button>
</div>

Practical Examples

1. Shared Utilities

<!-- DataTable.svelte -->
<script module>
  // Shared formatters used by all DataTable instances
  export const formatters = {
    currency: (value) => `$${value.toFixed(2)}`,
    date: (value) => new Date(value).toLocaleDateString(),
    percentage: (value) => `${(value * 100).toFixed(1)}%`
  };

  // Shared validation
  export function validateColumn(column) {
    return column.key && column.label;
  }
</script>

<script>
  let { columns, data, formatter } = $props();

  // Each instance can use the shared formatters
  function formatCell(value, column) {
    const fmt = formatters[column.type] || ((v) => v);
    return fmt(value);
  }
</script>

2. Global State/Registry

<!-- Modal.svelte -->
<script module>
  // Global modal registry - shared across all modal instances
  const openModals = new Set();

  export function getOpenModalCount() {
    return openModals.size;
  }

  // Prevent body scroll when any modal is open
  function updateBodyScroll() {
    document.body.style.overflow = openModals.size > 0 ? 'hidden' : '';
  }
</script>

<script>
  let { open = false } = $props();
  let modalId = Math.random().toString(36);

  // Each modal instance manages its own state but updates global registry
  $effect(() => {
    if (open) {
      openModals.add(modalId);
    } else {
      openModals.delete(modalId);
    }
    updateBodyScroll();

    return () => {
      openModals.delete(modalId);
      updateBodyScroll();
    };
  });
</script>

3. Component-specific Constants

<!-- Chart.svelte -->
<script module>
  // Chart types available to all Chart instances
  export const CHART_TYPES = {
    LINE: 'line',
    BAR: 'bar',
    PIE: 'pie'
  };

  // Default themes
  export const THEMES = {
    light: { bg: '#fff', text: '#000' },
    dark: { bg: '#000', text: '#fff' }
  };

  // Validation function
  export function isValidChartType(type) {
    return Object.values(CHART_TYPES).includes(type);
  }
</script>

<script>
  let { type = CHART_TYPES.LINE, theme = 'light', data } = $props();

  // Each chart instance uses the shared constants
  if (!isValidChartType(type)) {
    throw new Error(`Invalid chart type: ${type}`);
  }
</script>

Important Characteristics

Module Script Execution Example

<!-- Counter.svelte -->
<script module>
  let totalInstances = 0;
  export function getTotalInstances() { return totalInstances; }
</script>

<script>
  totalInstances++; // Increments for each new Counter component
  console.log('Total Counter instances:', totalInstances);
</script>

Module variables are shared

<!-- App.svelte -->
<script>
  import Counter from './Counter.svelte';
</script>

<!-- Each of these shares the same totalInstances counter -->
<Counter />
<Counter />
<Counter />
<!-- Console will show: 1, 2, 3 -->

Use Cases for <script module>

  • Shared utilities that all instances need
  • Constants used across instances
  • Global state that needs to persist across component instances
  • Validation functions or type definitions
  • Component registries or instance tracking

The key insight is that it's tied to the module system - when you import MyComponent from './MyComponent.svelte', that's when the module script runs, not when you use <MyComponent /> in your template.


Two-Way Binding with $bindable

$bindable is Svelte 5's way to create two-way binding between parent and child components. It's the replacement for Svelte 4's bind: directive on component props.

Basic Example

Child Component (Input.svelte)

<script>
  // $bindable creates a two-way bindable prop
  let { value = $bindable('') } = $props();
</script>

<input bind:value />

Parent Component

<script>
  import Input from './Input.svelte';

  let userInput = $state('');
</script>

<!-- Two-way binding: changes flow both directions -->
<Input bind:value={userInput} />
<p>You typed: {userInput}</p>

Custom Form Controls Example

NumberInput.svelte

<script>
  let {
    value = $bindable(0),
    min = 0,
    max = 100,
    step = 1,
    label = ''
  } = $props();

  function increment() {
    if (value < max) value += step;
  }

  function decrement() {
    if (value > min) value -= step;
  }
</script>

<div class="number-input">
  <label>{label}</label>
  <button onclick={decrement} disabled={value <= min}>-</button>
  <input type="number" bind:value {min} {max} {step} />
  <button onclick={increment} disabled={value >= max}>+</button>
</div>

Using the Custom Component

<script>
  let quantity = $state(1);
  let price = $state(10.99);

  let total = $state.derived(() => quantity * price);
</script>

<!-- Both quantity and price are bindable -->
<NumberInput bind:value={quantity} label="Quantity" min={1} max={10} />
<NumberInput bind:value={price} label="Price" min={0} step={0.01} />

<p>Total: ${total.toFixed(2)}</p>

Multi-Select Component Example

MultiSelect.svelte

<script>
  let {
    options = [],
    selected = $bindable([]),
    placeholder = 'Select options...'
  } = $props();

  function toggleOption(option) {
    if (selected.includes(option)) {
      selected = selected.filter(item => item !== option);
    } else {
      selected = [...selected, option];
    }
  }

  function isSelected(option) {
    return selected.includes(option);
  }
</script>

<div class="multi-select">
  <div class="selected-display">
    {#if selected.length === 0}
      {placeholder}
    {:else}
      {selected.join(', ')}
    {/if}
  </div>

  <div class="options">
    {#each options as option}
      <label>
        <input
          type="checkbox"
          checked={isSelected(option)}
          onchange={() => toggleOption(option)}
        />
        {option}
      </label>
    {/each}
  </div>
</div>

Using MultiSelect

<script>
  let selectedFruits = $state([]);
  let selectedColors = $state(['red']);

  const fruits = ['apple', 'banana', 'cherry', 'date'];
  const colors = ['red', 'green', 'blue', 'yellow'];
</script>

<MultiSelect
  bind:selected={selectedFruits}
  options={fruits}
  placeholder="Choose fruits..."
/>

<MultiSelect
  bind:selected={selectedColors}
  options={colors}
  placeholder="Choose colors..."
/>

<p>Selected fruits: {selectedFruits}</p>
<p>Selected colors: {selectedColors}</p>

Key Patterns and Best Practices

1. Default Values

<script>
  // Bindable with default value
  let { count = $bindable(0) } = $props();

  // Optional bindable (can be undefined)
  let { value = $bindable() } = $props();
</script>

2. Validation in Bindable Props

<script>
  let {
    email = $bindable(''),
    isValid = $bindable(false)
  } = $props();

  // Validate and update isValid when email changes
  $effect(() => {
    isValid = email.includes('@') && email.includes('.');
  });
</script>

<input bind:value={email} type="email" />

3. Multiple Bindable Props

<script>
  let {
    x = $bindable(0),
    y = $bindable(0),
    dragging = $bindable(false)
  } = $props();

  function handleDrag(event) {
    if (dragging) {
      x = event.clientX;
      y = event.clientY;
    }
  }
</script>

<div
  style="position: absolute; left: {x}px; top: {y}px;"
  onmousedown={() => dragging = true}
  onmousemove={handleDrag}
  onmouseup={() => dragging = false}
>
  Drag me!
</div>

When to Use $bindable

  • Form controls - custom inputs, selects, toggles
  • Interactive components - sliders, date pickers, color pickers
  • State that needs to flow both ways - modal open/close state
  • Synchronizing parent-child state - when child needs to update parent

When NOT to Use $bindable

  • One-way data flow - use regular props
  • Event-based communication - use callback props
  • Complex state management - use stores or context

The key benefit is that $bindable makes two-way binding explicit and type-safe, replacing the magic of Svelte 4's bind: directive with a clear rune-based approach!

SvelteKit SSR and Loader Functions Guide

A comprehensive guide to understanding SvelteKit's Server-Side Rendering (SSR) and data loading mechanisms using Svelte 5 syntax.

Table of Contents

  1. SSR Request Flow
  2. Universal Load Functions
  3. Hooks System
  4. Smart Fetch Behavior
  5. Parent Function Usage
  6. Data Invalidation
  7. Client-Side API Calls
  8. Form Actions

SSR Request Flow

Understanding how SvelteKit handles server-side rendering requires examining the complete request lifecycle and how different components work together.

File Structure Example

Let's say we have this file structure:

src/
├── hooks.server.ts
├── hooks.client.ts
├── routes/
│   ├── +layout.ts
│   ├── +layout.server.ts
│   ├── +layout.svelte
│   └── blog/
│       └── [slug]/
│           ├── +page.ts
│           ├── +page.server.ts
│           └── +page.svelte

Example URL: /blog/hello-world

Request Lifecycle Overview

When a user visits a SvelteKit page, the request goes through four distinct phases:

  1. Server-Side Request Handling - Hooks and load functions execute
  2. Server Rendering - Components render to HTML
  3. Client-Side Hydration - JavaScript takes over in the browser
  4. Client-Side Navigation - Subsequent page changes

Phase 1: Server-Side Request Handling

1. Server Hooks Execute First

// This intercepts the request before anything else
export async function handle({ event, resolve }) {
  // Add user session, modify request, etc.
  event.locals.user = await authenticateUser(event);

  const response = await resolve(event);
  return response;
}

2. Root Layout Server Load

export async function load({ locals }) {
  return {
    user: locals.user // Server-only data
  };
}

3. Root Layout Universal Load

export async function load({ data, fetch }) {
  // 'data' is from +layout.server.ts
  // Runs on server during SSR, then on client for navigation
  return {
    ...data,
    settings: await fetch('/api/settings').then(r => r.json())
  };
}

4. Page Server Load

export async function load({ params, parent }) {
  const post = await db.posts.findOne({ slug: params.slug });

  return {
    post // Server-only, won't be sent to client as code
  };
}

5. Page Universal Load

export async function load({ data, parent }) {
  // 'data' is from +page.server.ts
  const layoutData = await parent(); // Gets all parent load data

  return {
    ...data,
    related: await fetchRelatedPosts(data.post.id)
  };
}

Phase 2: Server Rendering

6. Component Tree Renders on Server

<!-- +layout.svelte (root) -->
<script>
  let { data, children } = $props();
  // data = merged from +layout.server.ts + +layout.ts
</script>

<header>User: {data.user?.name}</header>
{@render children()}
<!-- +page.svelte -->
<script>
  let { data } = $props();
  // data = merged from +page.server.ts + +page.ts
</script>

<article>
  <h1>{data.post.title}</h1>
  <p>{data.post.content}</p>
</article>

7. HTML Generation and Response

The server sends fully-rendered HTML with:

  • Inline JSON containing all the load data
  • Links to JS bundles for hydration

Phase 3: Client-Side Hydration

8. Client Hooks Execute

export async function handleError({ error, event }) {
  // Client-side error handling
  console.error('Client error:', error);
}

9. Svelte Hydration Process

  • Attaches event listeners
  • Makes the page interactive
  • The data is already there, no re-fetch needed

Phase 4: Client-Side Navigation

When user navigates to /blog/another-post:

10. Universal Load Functions Execute

+layout.ts runs → +page.ts runs

The .server.ts functions are called via internal fetch requests, but they don't run in the browser.

Execution Order Summary

SERVER (initial request):
1. hooks.server.ts (handle)
2. +layout.server.ts (root)
3. +layout.ts (root)
4. +page.server.ts
5. +page.ts
6. Render all .svelte components
7. Send HTML + serialized data

CLIENT (initial load):
8. hooks.client.ts
9. Hydrate components (no data re-fetch)

CLIENT (navigation):
10. +layout.ts (if layout changed)
11. +page.ts (always)
12. Re-render only changed components

Key File Type Distinctions

.server.ts files:

  • Only run on server
  • Can access databases, secrets, locals
  • Never sent to client
  • During client navigation, called via internal API

.ts files (universal):

  • Run on server during SSR
  • Run on client during navigation
  • Can use fetch (works everywhere)
  • Code is sent to client

hooks.server.ts:

  • Runs on every server request
  • Can modify event.locals
  • Perfect for auth, logging

hooks.client.ts:

  • Runs once when app starts in browser
  • Good for error tracking, analytics

Universal Load Functions

Universal load functions have a nuanced execution pattern that's crucial to understand for optimal performance.

Execution Context

Initial page visit (SSR):

  • Universal +page.ts runs only on the server
  • The returned data is serialized and sent to the client
  • On hydration, the function does NOT re-run in the browser
  • The client just uses the data that came with the HTML

Client-side navigation:

  • Universal +page.ts runs only in the browser
  • It fetches data and updates the page without a full page reload

So it's not "server then client" for the same request — it's either/or depending on how the page was reached.

Practical Example

// +page.ts
export async function load({ fetch }) {
  console.log('Loading posts...');
  const posts = await fetch('/api/posts').then(r => r.json());
  return { posts };
}

Scenario 1: User visits /blog directly (types URL)

  • Console shows "Loading posts..." on server only
  • HTML arrives with posts data already included
  • Browser hydrates, no re-run, no console log

Scenario 2: User clicks <a href="/blog"> from another page

  • Console shows "Loading posts..." in browser only
  • Data fetched client-side, page updates smoothly

Benefits of Universal Loaders

1. Code Reusability

You write the data-fetching logic once, and it works for both SSR and client navigation:

// +page.ts - works everywhere!
export async function load({ fetch, params }) {
  const product = await fetch(`/api/products/${params.id}`).then(r => r.json());
  return { product };
}

Without universal loaders, you'd need separate logic for server and client.

2. Client-Side Navigation Performance

Universal loaders enable SvelteKit's smooth, app-like navigation:

// +page.ts
export async function load({ fetch }) {
  return {
    posts: await fetch('/api/posts').then(r => r.json())
  };
}

When navigating between pages, this runs in the browser — no full page reload, instant transitions.

3. Data Sharing Between Contexts

// +page.server.ts - server-only secrets
export async function load() {
  const data = await db.query('SELECT * FROM posts');
  return { posts: data };
}

// +page.ts - enhance with client-safe operations
export async function load({ data, fetch }) {
  const analytics = await fetch('/api/analytics').then(r => r.json());

  return {
    ...data, // posts from server
    analytics, // fetched universally
    timestamp: Date.now() // added on whichever context runs
  };
}

4. Progressive Enhancement

Universal loaders let you build features that work with or without JavaScript:

// +page.ts
export async function load({ fetch, url }) {
  const searchTerm = url.searchParams.get('q');

  // Works on server (initial load) and client (search updates)
  const results = await fetch(`/api/search?q=${searchTerm}`).then(r => r.json());
  return { results };
}
  • With JS: Smooth client-side search
  • Without JS: Still works via form submission and full page reload

When to Skip Universal Loaders

Skip +page.ts when:

A) You only need server data:

// Just +page.server.ts is enough
export async function load() {
  const secrets = await getAPIKeys();
  return { data: await fetchWithSecrets(secrets) };
}

B) You don't need data at all:

<!-- Just +page.svelte -->
<script>
  let count = $state(0);
</script>

<button onclick={() => count++}>{count}</button>

Mental Model

Think of it this way:

  • .server.ts = "This MUST run on the server" (secrets, DB access)
  • .ts = "This CAN run anywhere" (public APIs, transformations)

Universal loaders are the bridge that makes SvelteKit feel like a SPA while maintaining SSR benefits.


Hooks System

SvelteKit provides two types of hooks that serve different purposes in the application lifecycle.

Server Hooks (hooks.server.ts)

These run on the server for every request before any load functions execute.

Primary Use Cases

Authentication & Authorization

// hooks.server.ts
export async function handle({ event, resolve }) {
  const sessionId = event.cookies.get('session');

  if (sessionId) {
    event.locals.user = await getUserFromSession(sessionId);
  }

  // Protect routes
  if (event.url.pathname.startsWith('/admin') && !event.locals.user?.isAdmin) {
    return new Response('Unauthorized', { status: 401 });
  }

  return resolve(event);
}

Request Modification & Logging

export async function handle({ event, resolve }) {
  const startTime = Date.now();

  console.log(`${event.request.method} ${event.url.pathname}`);

  const response = await resolve(event);

  console.log(`Response time: ${Date.now() - startTime}ms`);
  return response;
}

Response Transformation

export async function handle({ event, resolve }) {
  const response = await resolve(event, {
    transformPageChunk: ({ html }) => {
      // Inject analytics, modify HTML before sending
      return html.replace('%analytics%', getAnalyticsScript());
    }
  });

  // Add security headers
  response.headers.set('X-Frame-Options', 'DENY');

  return response;
}

Setting event.locals

export async function handle({ event, resolve }) {
  // Make data available to ALL load functions
  event.locals.userAgent = event.request.headers.get('user-agent');
  event.locals.db = createDatabaseConnection();

  return resolve(event);
}

Server-Side Error Handling

export async function handleError({ error, event, status, message }) {
  // Log to external service
  await logToSentry({
    error,
    url: event.url.pathname,
    user: event.locals.user
  });

  // Return safe error to client
  return {
    message: 'Something went wrong'
  };
}

When to Use Server Hooks

✅ Authentication/session management ✅ Setting up database connections ✅ Request logging and monitoring ✅ Setting security headers ✅ API rate limiting ✅ Redirect logic that applies globally ✅ Anything that needs to run before load functions

Client Hooks (hooks.client.ts)

These run in the browser when the app initializes (once per session).

Primary Use Cases

Client-Side Error Tracking

// hooks.client.ts
export async function handleError({ error, event, status, message }) {
  // Send to analytics/monitoring
  if (typeof window !== 'undefined') {
    analytics.trackError({
      error: error.message,
      path: event.url.pathname,
      status
    });
  }

  return {
    message: 'Oops! Something went wrong.'
  };
}

Navigation Lifecycle Hooks

export async function handleFetch({ event, request, fetch }) {
  // Modify fetch requests during client-side navigation

  // Add auth token to API calls
  if (request.url.startsWith('/api')) {
    request.headers.set('Authorization', `Bearer ${getToken()}`);
  }

  return fetch(request);
}

Global Client Setup

// This pattern isn't directly in hooks but shows the concept
export function handleError({ error, event }) {
  // Initialize client-side services once
  if (!window.__analyticsInitialized) {
    initAnalytics();
    window.__analyticsInitialized = true;
  }

  return { message: 'Error occurred' };
}

When to Use Client Hooks

✅ Client-side error logging (Sentry, LogRocket, etc.) ✅ Modifying fetch requests during navigation ✅ Analytics tracking ✅ Global error display logic ✅ Client-side request interceptors

Comparison Table

Aspect hooks.server.ts hooks.client.ts
Runs On server for every request In browser once on app start
Access to Secrets, DB, event.locals Browser APIs, localStorage
Frequency Every request (SSR + API) Once per session
Can modify Server response, HTML Client-side fetch requests
Typical use Auth, logging, security Error tracking, analytics

Complete Authentication Example

// hooks.server.ts
export async function handle({ event, resolve }) {
  const token = event.cookies.get('auth_token');

  if (token) {
    try {
      event.locals.user = await verifyToken(token);
    } catch {
      event.cookies.delete('auth_token', { path: '/' });
    }
  }

  // Protect routes
  if (event.url.pathname.startsWith('/dashboard') && !event.locals.user) {
    return Response.redirect(new URL('/login', event.url), 303);
  }

  return resolve(event);
}

export function handleError({ error, event }) {
  // Log server errors
  console.error('Server error:', {
    error,
    path: event.url.pathname,
    user: event.locals.user?.id
  });

  return { message: 'Internal server error' };
}
// hooks.client.ts
export function handleError({ error, event }) {
  // Track client errors
  if (window.analytics) {
    window.analytics.track('Error', {
      message: error.message,
      path: event.url.pathname
    });
  }

  // Show user-friendly message
  return {
    message: 'Something went wrong. Please try again.'
  };
}

export async function handleFetch({ request, fetch }) {
  // Add client-side request headers
  if (request.url.includes('/api/')) {
    const token = localStorage.getItem('csrf_token');
    if (token) {
      request.headers.set('X-CSRF-Token', token);
    }
  }

  return fetch(request);
}

Common Implementation Patterns

Pattern 1: Chain Multiple Server Hooks

// hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';

async function authenticate({ event, resolve }) {
  // Auth logic
  return resolve(event);
}

async function logging({ event, resolve }) {
  // Logging logic
  return resolve(event);
}

export const handle = sequence(authenticate, logging);

Pattern 2: Conditional Hook Logic

export async function handle({ event, resolve }) {
  // Skip auth for public routes
  const publicRoutes = ['/login', '/signup', '/api/public'];
  const isPublic = publicRoutes.some(route =>
    event.url.pathname.startsWith(route)
  );

  if (!isPublic) {
    // Auth check
  }

  return resolve(event);
}

Summary

Server Hooks = Bouncer at the door

  • Checks credentials before anyone enters
  • Decides who gets in
  • Runs for EVERY visitor

Client Hooks = Personal assistant in your pocket

  • Helps you once you're inside
  • Modifies your experience
  • Runs once per session

Smart Fetch Behavior

Understanding SvelteKit's fetch behavior during client-side navigation is crucial for performance optimization.

Fetch Behavior During Navigation

When client-side navigation happens and a universal load function runs in the browser:

Scenario 1: Fetching from API Routes

// +page.ts
export async function load({ fetch }) {
  // This DOES make a fetch call to the server
  const data = await fetch('/api/posts').then(r => r.json());
  return { data };
}

✅ Yes, this makes an actual HTTP request to your server's /api/posts endpoint.

Scenario 2: Getting Data from Server Load Functions

// +page.server.ts
export async function load() {
  const posts = await db.query('SELECT * FROM posts');
  return { posts };
}

// +page.ts
export async function load({ data }) {
  // 'data' contains the posts from +page.server.ts
  // NO fetch call needed - it's already there!
  return {
    ...data,
    clientTimestamp: Date.now()
  };
}

❌ No fetch call! SvelteKit automatically gets the data from the server load function via an internal mechanism.

But if you have ONLY +page.server.ts (no +page.ts):

// +page.server.ts
export async function load() {
  return { posts: await db.query('SELECT * FROM posts') };
}

During client-side navigation, SvelteKit makes an internal fetch request to get this data (it calls the server load function via a special endpoint).

Scenario 3: Fetching External APIs

// +page.ts
export async function load({ fetch }) {
  // This goes directly to the external API from the browser
  const data = await fetch('https://api.github.com/users/sveltejs').then(r => r.json());
  return { data };
}

✅ Yes, makes a fetch call, but directly to the external API (not through your server).

SvelteKit's Smart Fetch Mechanism

During client-side navigation, here's what happens:

// +page.server.ts
export async function load() {
  return { serverData: 'from database' };
}

// +page.ts
export async function load({ data, fetch }) {
  // 'data' from +page.server.ts arrives automatically
  // SvelteKit makes an internal request to get it

  const apiData = await fetch('/api/posts').then(r => r.json());
  // ^ This is an explicit fetch YOU wrote

  return {
    ...data,
    apiData
  };
}

What actually happens on client navigation:

  1. SvelteKit sees you need +page.server.ts data
  2. It makes an internal request: GET /blog/[slug]/__data.json
  3. This special endpoint runs your server load function and returns JSON
  4. Your +page.ts receives this as data
  5. Then your explicit fetch('/api/posts') call executes

Request Flow Comparison

Initial SSR (Server)

Browser → Server
         ↓
    +page.server.ts runs
         ↓
    +page.ts runs (on server)
         ↓
    HTML + JSON sent to browser

Client Navigation

Browser click link
         ↓
    Fetch /__data.json → Server
                         ↓
                    +page.server.ts runs
                         ↓
                    JSON returned
         ↓
    +page.ts runs (in browser)
         ↓
    Calls fetch('/api/posts') if needed → Server
         ↓
    Page updates

Complete Example

// routes/products/[id]/+page.server.ts
export async function load({ params }) {
  // Database access (server-only)
  const product = await db.products.findById(params.id);
  return { product };
}

// routes/products/[id]/+page.ts
export async function load({ data, fetch, params }) {
  // data.product is automatically fetched from server during navigation

  // Explicit fetch calls you write:
  const [reviews, relatedProducts] = await Promise.all([
    fetch(`/api/products/${params.id}/reviews`).then(r => r.json()),
    fetch(`/api/products/${params.id}/related`).then(r => r.json())
  ]);

  return {
    ...data, // product from server
    reviews, // from fetch
    relatedProducts // from fetch
  };
}

During client navigation to /products/123:

  1. SvelteKit fetches /products/123/__data.json → gets product
  2. Your +page.ts runs in browser
  3. Fetches /api/products/123/reviews → your API route
  4. Fetches /api/products/123/related → your API route
  5. Page renders with all data

So, to directly answer your question

Yes, universal load functions can make fetch calls during client-side navigation, but:

  • Data from +page.server.ts arrives automatically (via internal __data.json endpoint)
  • Any additional fetch() calls you write in +page.ts are explicit network requests
  • These can go to your API routes or external APIs

The beauty is you don't have to think about it much—just write your fetch logic once, and it works correctly in both contexts!

Does this clarify the fetch behavior?

Custom Headers and Server-to-Server Calls

Two important aspects of SvelteKit's fetch behavior that affect how you handle authentication and internal API calls.

Custom Headers in Fetch

SvelteKit's fetch only forwards a subset of headers automatically.

What gets forwarded automatically:

// +page.ts (during SSR)
export async function load({ fetch }) {
  // These headers are automatically forwarded from the original request:
  // - cookie
  // - authorization
  // - x-sveltekit-*

  const data = await fetch('/api/posts').then(r => r.json());
  return { data };
}

Custom headers need explicit passing:

// +page.server.ts
export async function load({ fetch, request }) {
  // Custom header from client request
  const customAuth = request.headers.get('x-custom-auth');

  // You MUST pass it explicitly:
  const data = await fetch('/api/posts', {
    headers: {
      'x-custom-auth': customAuth
    }
  }).then(r => r.json());

  return { data };
}

Common pattern for API keys or custom auth:

// +page.server.ts
export async function load({ fetch, request, cookies }) {
  const apiKey = request.headers.get('x-api-key');
  const csrfToken = cookies.get('csrf_token');

  const data = await fetch('/api/protected', {
    headers: {
      'x-api-key': apiKey || '',
      'x-csrf-token': csrfToken || ''
    }
  }).then(r => r.json());

  return { data };
}

Using helper functions for consistent forwarding:

// lib/utils.ts
export function getAuthHeaders(request: Request) {
  return {
    'x-custom-auth': request.headers.get('x-custom-auth'),
    'x-tenant-id': request.headers.get('x-tenant-id')
  };
}

// +page.server.ts
export async function load({ fetch, request }) {
  const data = await fetch('/api/data', {
    headers: getAuthHeaders(request)
  }).then(r => r.json());

  return { data };
}

Server-to-Server Calls Go Through Hooks

Even when +page.server.ts calls a +server.ts endpoint on the same server, it goes through the full request cycle, including hooks.

Example to demonstrate:

// hooks.server.ts
export async function handle({ event, resolve }) {
  console.log(`🔥 Hook: ${event.request.method} ${event.url.pathname}`);

  // Auth check
  if (event.url.pathname.startsWith('/api/')) {
    const token = event.request.headers.get('authorization');
    if (!token) {
      return new Response('Unauthorized', { status: 401 });
    }
    event.locals.user = await verifyToken(token);
  }

  return resolve(event);
}
// routes/api/posts/+server.ts
export async function GET({ locals }) {
  console.log('📝 API route running');
  console.log('User from locals:', locals.user);

  return json({ posts: await db.posts.findAll() });
}
// routes/blog/+page.server.ts
export async function load({ fetch }) {
  console.log('📄 Page server load running');

  // This goes through the hook!
  const posts = await fetch('/api/posts', {
    headers: {
      'authorization': 'Bearer token123'
    }
  }).then(r => r.json());

  return { posts };
}

Console output:

📄 Page server load running
🔥 Hook: GET /api/posts
📝 API route running
User from locals: { id: 1, name: 'John' }

Important implications:

1. You need to pass auth credentials even for internal calls:

// +page.server.ts
export async function load({ fetch, locals }) {
  // Even though we're on the same server, the hook will check auth

  // ❌ This will fail if your hook requires auth:
  const data = await fetch('/api/protected').then(r => r.json());

  // ✅ Must include auth header:
  const data = await fetch('/api/protected', {
    headers: {
      'authorization': `Bearer ${locals.sessionToken}`
    }
  }).then(r => r.json());

  return { data };
}

2. Hooks run multiple times during SSR:

// hooks.server.ts
export async function handle({ event, resolve }) {
  console.log('Hook ran for:', event.url.pathname);
  return resolve(event);
}

// User visits /blog
// Console:
// Hook ran for: /blog          (page request)
// Hook ran for: /api/posts     (fetch from page.server.ts)

3. Performance consideration - bypassing hooks:

If you want to skip the hook for internal calls, don't use fetch:

// lib/db.ts
export async function getPosts() {
  return db.posts.findAll();
}

// routes/api/posts/+server.ts
import { getPosts } from '$lib/db';

export async function GET() {
  return json({ posts: await getPosts() });
}

// routes/blog/+page.server.ts
import { getPosts } from '$lib/db';

export async function load() {
  // Direct DB call - no HTTP, no hooks, faster!
  return { posts: await getPosts() };
}

This is often preferred because:

  • ✅ Faster (no HTTP overhead)
  • ✅ No double hook execution
  • ✅ Direct access to DB/services
  • ✅ No need to manage internal auth tokens

When to use fetch vs direct imports:

Use fetch() for API routes when:

  • You want consistent auth/authorization flow
  • The API is also used by external clients
  • You need request/response logging
  • You want to test the full HTTP flow

Use direct imports when:

  • Pure server-to-server data access
  • Performance is critical
  • You trust the calling context
  • The logic isn't exposed as an API endpoint

Practical Pattern: Combining Both Approaches

// lib/posts.server.ts
export async function getPosts(userId?: string) {
  if (userId) {
    return db.posts.where('userId', userId).findAll();
  }
  return db.posts.findAll();
}

// routes/api/posts/+server.ts
import { getPosts } from '$lib/posts.server';

export async function GET({ locals }) {
  // Hook already validated auth and set locals.user
  return json({
    posts: await getPosts(locals.user?.id)
  });
}

// routes/blog/+page.server.ts
import { getPosts } from '$lib/posts.server';

export async function load({ locals }) {
  // Skip the HTTP layer entirely, direct call
  return {
    posts: await getPosts(locals.user?.id)
  };
}

This way:

  • External clients use /api/posts (goes through hooks)
  • Internal page loads call the function directly (faster, no hooks)
  • Both use the same business logic

Parent Function Usage

The parent() function is a powerful but nuanced feature that affects load function execution order and performance.

What parent() Does

parent() returns a promise that resolves to the merged data from all parent load functions.

// routes/+layout.server.ts
export async function load() {
  return { layoutData: 'from root layout' };
}

// routes/blog/+layout.server.ts
export async function load() {
  return { blogLayoutData: 'from blog layout' };
}

// routes/blog/[slug]/+page.server.ts
export async function load({ parent }) {
  const parentData = await parent();
  // parentData = {
  //   layoutData: 'from root layout',
  //   blogLayoutData: 'from blog layout'
  // }

  return { ...parentData, pageData: 'from page' };
}

Serial vs Parallel Execution

Without parent() - Parallel (faster)

// routes/+layout.server.ts
export async function load() {
  await new Promise(resolve => setTimeout(resolve, 100)); // 100ms
  return { user: { name: 'John' } };
}

// routes/blog/+page.server.ts
export async function load() {
  // Doesn't call parent()
  await new Promise(resolve => setTimeout(resolve, 100)); // 100ms
  return { posts: [] };
}

// Total time: ~100ms (parallel execution)

With parent() - Serial (slower)

// routes/+layout.server.ts
export async function load() {
  await new Promise(resolve => setTimeout(resolve, 100)); // 100ms
  return { user: { name: 'John' } };
}

// routes/blog/+page.server.ts
export async function load({ parent }) {
  await parent(); // Must wait for layout to finish
  await new Promise(resolve => setTimeout(resolve, 100)); // 100ms
  return { posts: [] };
}

// Total time: ~200ms (serial execution)

When to Use parent()

Use parent() when you need parent data:

// routes/+layout.server.ts
export async function load() {
  return { userId: 123 };
}

// routes/profile/+page.server.ts
export async function load({ parent }) {
  const { userId } = await parent();

  // Need userId from parent to fetch user profile
  const profile = await db.profiles.findOne({ userId });
  return { profile };
}

Use when child depends on parent's computation:

// routes/+layout.server.ts
export async function load() {
  const tenant = await identifyTenant();
  return { tenant };
}

// routes/dashboard/+page.server.ts
export async function load({ parent }) {
  const { tenant } = await parent();

  // Dashboard data is tenant-specific
  const data = await db.query('SELECT * FROM data WHERE tenant_id = ?', [tenant.id]);
  return { data };
}

Don't use parent() if you don't need parent data:

// routes/+layout.server.ts
export async function load() {
  await fetchSomeData(); // slow operation
  return { layoutData: 'something' };
}

// routes/blog/+page.server.ts
export async function load({ parent }) {
  await parent(); // ❌ Unnecessary wait!

  // Not using parent data at all
  const posts = await db.posts.findAll();
  return { posts };
}

// Better:
export async function load() {
  // ✅ Runs in parallel with layout
  const posts = await db.posts.findAll();
  return { posts };
}

Where parent() Can Be Used

1. In Page Load Functions (both server and universal):

// routes/blog/[slug]/+page.server.ts
export async function load({ parent }) {
  const parentData = await parent();
  return { ...parentData, pageSpecific: 'data' };
}

// routes/blog/[slug]/+page.ts
export async function load({ parent }) {
  const parentData = await parent();
  return { ...parentData, clientEnhanced: true };
}

2. In Layout Load Functions:

// routes/+layout.server.ts (root)
export async function load() {
  return { rootData: 'from root' };
}

// routes/blog/+layout.server.ts (nested)
export async function load({ parent }) {
  const { rootData } = await parent();
  return { rootData, blogData: 'from blog layout' };
}

// routes/blog/posts/+layout.server.ts (deeply nested)
export async function load({ parent }) {
  const { rootData, blogData } = await parent();
  return { rootData, blogData, postsData: 'from posts layout' };
}

3. Across server and universal boundaries:

// routes/+layout.server.ts
export async function load() {
  return { serverData: 'secret' };
}

// routes/+layout.ts
export async function load({ parent, data }) {
  // 'data' is from +layout.server.ts
  // parent() would return nothing (no parent universal load)
  return { ...data, universalData: 'public' };
}

// routes/blog/+page.ts
export async function load({ parent }) {
  const parentData = await parent();
  // parentData = { serverData: 'secret', universalData: 'public' }
  return { ...parentData, pageData: 'more' };
}

Important: parent() Merges Across Load Types

The chain includes both .server.ts and .ts files:

// routes/+layout.server.ts
export async function load() {
  return { a: 1 };
}

// routes/+layout.ts
export async function load({ data }) {
  return { ...data, b: 2 }; // { a: 1, b: 2 }
}

// routes/blog/+layout.server.ts
export async function load() {
  return { c: 3 };
}

// routes/blog/+page.ts
export async function load({ parent }) {
  const parentData = await parent();
  // parentData = { a: 1, b: 2, c: 3 }

  return { ...parentData, d: 4 };
}

Performance Optimization Patterns

Pattern 1: Parallel with selective parent access:

// routes/+layout.server.ts
export async function load() {
  const [user, settings] = await Promise.all([
    db.users.find(),
    db.settings.find()
  ]);
  return { user, settings };
}

// routes/blog/+page.server.ts
export async function load({ parent }) {
  // Fetch posts in parallel with parent
  const [parentData, posts] = await Promise.all([
    parent(),
    db.posts.findAll()
  ]);

  // Use user from parent for filtering
  const filteredPosts = posts.filter(p => p.authorId === parentData.user.id);

  return { ...parentData, posts: filteredPosts };
}

Pattern 2: Conditional parent access:

// routes/dashboard/+page.server.ts
export async function load({ parent, url }) {
  const needsUserData = url.searchParams.has('user');

  if (needsUserData) {
    const { user } = await parent();
    // Use user data
    return { data: await fetchUserSpecificData(user.id) };
  }

  // No parent needed, run in parallel
  return { data: await fetchGeneralData() };
}

Pattern 3: Extract only what you need early:

// routes/blog/+page.server.ts
export async function load({ parent }) {
  // Start fetching posts immediately
  const postsPromise = db.posts.findAll();

  // Get parent data
  const { user } = await parent();

  // Wait for posts
  const posts = await postsPromise;

  // Filter with user data
  return {
    posts: posts.filter(p => canUserView(p, user))
  };
}

Common Mistakes

❌ Mistake 1: Unnecessary awaiting:

export async function load({ parent }) {
  const parentData = await parent();

  // Not using parentData at all!
  return { posts: await db.posts.findAll() };
}

❌ Mistake 2: Double awaiting:

export async function load({ parent, data }) {
  const parentData = await parent();

  // data already contains parent server load data!
  // No need for parent() if you just want server data
  return { ...data, extra: 'stuff' };
}

❌ Mistake 3: Breaking parallelism unnecessarily:

export async function load({ parent }) {
  await parent(); // Wait even though we don't need the data

  // These could run in parallel with parent
  const a = await fetchA();
  const b = await fetchB();

  return { a, b };
}

Guidelines Summary

Scenario Use parent()? Reason
Need parent data ✅ Yes Required dependency
Don't need parent data ❌ No Maintain parallelism
Need only server parent data in universal load ❌ No Use data prop instead
Nested layouts building on each other ✅ Yes Data accumulation pattern
Independent page data ❌ No Performance optimization
Conditional dependency ⚠️ Maybe Start fetching early, await parent later

The Mental Model

Think of parent() as creating a waterfall:

Without parent():     With parent():
Layout  ████          Layout  ████
Page    ████          Page         ████

Time →                Time →

Only use parent() when the waterfall is necessary (child needs parent's result). Otherwise, let everything run in parallel for better performance.

Does this clarify when and how to use parent()?


Data Invalidation

Excellent question! This is crucial for understanding SvelteKit's reactivity model. Let me break down all the ways server load functions can re-execute.

Events That Trigger Server Load Re-execution

1. Navigation to the Same Route with Different Params

// routes/blog/[slug]/+page.server.ts
export async function load({ params }) {
  return { post: await db.posts.findOne({ slug: params.slug }) };
}

Triggers re-execution:

  • User navigates from /blog/hello/blog/world
  • Params changed, so load runs again

Doesn't trigger:

  • User navigates from /blog/hello/blog/hello (same params)

2. Search Params Change (URL Query Parameters)

// routes/products/+page.server.ts
export async function load({ url }) {
  const category = url.searchParams.get('category');
  return { products: await db.products.findByCategory(category) };
}

Triggers re-execution:

  • /products?category=phones/products?category=laptops

Doesn't trigger by default:

  • /products?category=phones/products?category=phones (same params)

3. Form Actions (use:enhance)

// routes/todos/+page.server.ts
export async function load() {
  return { todos: await db.todos.findAll() };
}

export const actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    await db.todos.create({ text: data.get('text') });
    // Load function automatically re-runs after successful action
  }
};
<!-- +page.svelte -->
<script>
  import { enhance } from '$app/forms';
  let { data } = $props();
</script>

<form method="POST" action="?/create" use:enhance>
  <input name="text" />
  <button>Add Todo</button>
</form>

<!-- After form submission, load() re-runs automatically -->

4. Manual Invalidation with invalidate()

// routes/posts/+page.server.ts
export async function load({ depends }) {
  // Register dependencies
  depends('posts:list');

  return { posts: await db.posts.findAll() };
}
<!-- +page.svelte -->
<script>
  import { invalidate } from '$app/navigation';
  let { data } = $props();

  async function refreshPosts() {
    // Re-runs ALL load functions that depend on 'posts:list'
    await invalidate('posts:list');
  }
</script>

<button onclick={refreshPosts}>Refresh Posts</button>

5. Manual Invalidation with invalidateAll()

<script>
  import { invalidateAll } from '$app/navigation';

  async function refreshEverything() {
    // Re-runs ALL load functions on the current page
    await invalidateAll();
  }
</script>

<button onclick={refreshEverything}>Refresh Everything</button>

6. Invalidation by URL with invalidate(url)

// routes/dashboard/+page.server.ts
export async function load({ fetch }) {
  const stats = await fetch('/api/stats').then(r => r.json());
  return { stats };
}
<script>
  import { invalidate } from '$app/navigation';

  async function refreshStats() {
    // Re-runs load functions that fetched from /api/stats
    await invalidate('/api/stats');
  }
</script>

7. Polling with setInterval

<script>
  import { invalidate } from '$app/navigation';
  import { onMount, onDestroy } from 'svelte';

  let interval: ReturnType<typeof setInterval>;

  onMount(() => {
    // Poll every 5 seconds
    interval = setInterval(() => {
      invalidate('realtime:data');
    }, 5000);
  });

  onDestroy(() => {
    clearInterval(interval);
  });
</script>

Understanding depends(), invalidate(), and invalidateAll()

depends() - Register Dependencies

Allows you to create custom dependency keys:

// routes/posts/+page.server.ts
export async function load({ depends }) {
  depends('app:posts');
  depends('app:user');

  const [posts, user] = await Promise.all([
    db.posts.findAll(),
    db.users.getCurrent()
  ]);

  return { posts, user };
}

// routes/comments/+page.server.ts
export async function load({ depends }) {
  depends('app:posts'); // Also depends on posts

  return { comments: await db.comments.findAll() };
}

invalidate(dependency) - Selective Invalidation

Re-runs only load functions that depend on the specified key:

<script>
  import { invalidate } from '$app/navigation';

  async function updatePosts() {
    await api.updatePost();

    // Only re-runs load functions with depends('app:posts')
    await invalidate('app:posts');
    // Both /posts and /comments pages would reload if visible
  }
</script>

invalidateAll() - Nuclear Option

Re-runs ALL load functions on the current page (layouts + page):

<script>
  import { invalidateAll } from '$app/navigation';

  async function hardRefresh() {
    // Re-runs:
    // - +layout.server.ts (root)
    // - +layout.ts (root)
    // - +layout.server.ts (nested)
    // - +page.server.ts
    // - +page.ts
    await invalidateAll();
  }
</script>

Practical Examples

Example 1: Real-time Dashboard

// routes/dashboard/+page.server.ts
export async function load({ depends }) {
  depends('dashboard:metrics');

  return {
    users: await db.users.count(),
    revenue: await db.orders.sum('total'),
    lastUpdated: Date.now()
  };
}
<!-- +page.svelte -->
<script>
  import { invalidate } from '$app/navigation';
  import { onMount, onDestroy } from 'svelte';

  let { data } = $props();
  let interval: ReturnType<typeof setInterval>;

  onMount(() => {
    interval = setInterval(() => {
      invalidate('dashboard:metrics');
    }, 10000); // Update every 10s
  });

  onDestroy(() => clearInterval(interval));
</script>

<div>
  <h1>Users: {data.users}</h1>
  <h2>Revenue: ${data.revenue}</h2>
  <small>Updated: {new Date(data.lastUpdated).toLocaleTimeString()}</small>
</div>

Example 2: Optimistic UI with Revalidation

// routes/todos/+page.server.ts
export async function load({ depends }) {
  depends('todos:list');
  return { todos: await db.todos.findAll() };
}

export const actions = {
  toggle: async ({ request }) => {
    const data = await request.formData();
    const id = data.get('id');
    await db.todos.toggle(id);
    // Load automatically re-runs after action
  }
};
<!-- +page.svelte -->
<script>
  import { enhance } from '$app/forms';
  import { invalidate } from '$app/navigation';

  let { data } = $props();

  async function deleteAndRefresh(id: string) {
    await fetch(`/api/todos/${id}`, { method: 'DELETE' });

    // Manually invalidate since we're not using form action
    await invalidate('todos:list');
  }
</script>

{#each data.todos as todo}
  <div>
    <form method="POST" action="?/toggle" use:enhance>
      <input type="hidden" name="id" value={todo.id} />
      <button>{todo.done ? '' : ''} {todo.text}</button>
    </form>
    <button onclick={() => deleteAndRefresh(todo.id)}>Delete</button>
  </div>
{/each}

Example 3: Multiple Dependencies

// routes/+layout.server.ts
export async function load({ depends }) {
  depends('app:user');
  return { user: await getCurrentUser() };
}

// routes/profile/+page.server.ts
export async function load({ depends, parent }) {
  depends('app:user');
  depends('profile:data');

  const { user } = await parent();

  return {
    profile: await db.profiles.findOne({ userId: user.id }),
    posts: await db.posts.findByUser(user.id)
  };
}

// routes/settings/+page.server.ts
export async function load({ depends }) {
  depends('app:user');
  depends('settings:preferences');

  return { settings: await db.settings.findAll() };
}
<!-- Anywhere in the app -->
<script>
  import { invalidate } from '$app/navigation';

  async function logout() {
    await api.logout();
    // Invalidates layout + profile page + settings page
    await invalidate('app:user');
  }

  async function updateProfile() {
    await api.updateProfile();
    // Only invalidates profile page, not settings
    await invalidate('profile:data');
  }
</script>

Example 4: URL-based Invalidation

// routes/dashboard/+page.server.ts
export async function load({ fetch }) {
  // Automatically tracked as dependency
  const [metrics, notifications] = await Promise.all([
    fetch('/api/metrics').then(r => r.json()),
    fetch('/api/notifications').then(r => r.json())
  ]);

  return { metrics, notifications };
}
<script>
  import { invalidate } from '$app/navigation';

  async function refreshMetrics() {
    // Only re-fetches /api/metrics, not /api/notifications
    await invalidate('/api/metrics');
  }

  async function refreshNotifications() {
    await invalidate('/api/notifications');
  }

  async function refreshBoth() {
    // Can invalidate multiple URLs
    await Promise.all([
      invalidate('/api/metrics'),
      invalidate('/api/notifications')
    ]);

    // Or just use invalidateAll()
  }
</script>

Important Behaviors

1. Automatic URL Dependency Tracking

export async function load({ fetch }) {
  // SvelteKit automatically tracks this URL as a dependency
  const data = await fetch('/api/data').then(r => r.json());
  return { data };
}

You can invalidate by URL without using depends():

await invalidate('/api/data');

2. Form Actions Auto-invalidate

export const actions = {
  update: async () => {
    await db.update();
    // No need to manually invalidate!
    // Load functions re-run automatically
  }
};

3. Invalidation is Scoped to Current Page

// On /blog page
await invalidate('posts:list');
// Only re-runs load functions on /blog

// Navigating to /about won't carry over the invalidation

4. Parent Load Functions Re-run When Invalidated

// +layout.server.ts
export async function load({ depends }) {
  depends('app:auth');
  return { user: await getUser() };
}

// +page.server.ts - doesn't need depends()
export async function load({ parent }) {
  const { user } = await parent();
  return { data: await getData(user.id) };
}
await invalidate('app:auth');
// Both layout AND page load functions re-run

When NOT to Use Invalidation

❌ Don't invalidate for client-only state:

<script>
  let count = $state(0);

  // ❌ Bad - no need to invalidate
  async function increment() {
    count++;
    await invalidateAll(); // Unnecessary server round-trip!
  }

  // ✅ Good - just update state
  function increment() {
    count++;
  }
</script>

❌ Don't invalidate when you can use reactive state:

<script>
  let { data } = $props();

  // ❌ Bad
  async function filter() {
    await invalidate('products:list'); // Unnecessary
  }

  // ✅ Good - filter client-side
  let filtered = $derived(
    data.products.filter(p => p.category === selectedCategory)
  );
</script>

Summary Table

Trigger Scope Use Case
Navigation Automatic New params/different route
Search params change Automatic Query string changes
Form actions Automatic After form submission
invalidate(key) Selective Refresh specific dependencies
invalidate(url) Selective Refresh specific API calls
invalidateAll() Everything Nuclear refresh option
Polling Manual Real-time updates

Does this clarify how server load re-execution works and when to use invalidation?


Client-Side API Calls

Great question! You have several options for making API calls after the page is rendered. Let me break down all the approaches:

Option 1: Using onMount (Most Common)

<script>
  import { onMount } from 'svelte';

  let { data } = $props();
  let liveData = $state(null);
  let loading = $state(false);

  onMount(async () => {
    loading = true;
    liveData = await fetch('/api/live-stats').then(r => r.json());
    loading = false;
  });
</script>

{#if loading}
  <p>Loading...</p>
{:else if liveData}
  <p>Live stats: {liveData.count}</p>
{/if}

When to use:

  • Data not needed for SSR
  • Client-only features (geolocation, browser APIs)
  • Heavy/slow queries that would slow down initial page load
  • Real-time data that needs to be fresh

Option 2: Event Handlers (User Interactions)

<script>
  let results = $state([]);
  let loading = $state(false);

  async function search(query: string) {
    loading = true;
    results = await fetch(`/api/search?q=${query}`).then(r => r.json());
    loading = false;
  }
</script>

<input
  type="text"
  oninput={(e) => search(e.currentTarget.value)}
/>

{#each results as result}
  <div>{result.title}</div>
{/each}

When to use:

  • Click handlers
  • Search/filter interactions
  • Lazy loading on scroll
  • Modal/dropdown content

Option 3: Reactive Statements with $effect

<script>
  let { data } = $props();
  let selectedCategory = $state('all');
  let products = $state([]);

  $effect(() => {
    // Runs whenever selectedCategory changes
    fetch(`/api/products?category=${selectedCategory}`)
      .then(r => r.json())
      .then(data => products = data);
  });
</script>

<select bind:value={selectedCategory}>
  <option value="all">All</option>
  <option value="electronics">Electronics</option>
</select>

{#each products as product}
  <div>{product.name}</div>
{/each}

When to use:

  • Data depends on reactive state
  • Need to refetch when certain values change
  • Auto-sync with user selections

⚠️ Warning: Be careful with $effect and async operations:

<script>
  let query = $state('');
  let results = $state([]);

  // ✅ Good - cleanup to prevent race conditions
  $effect(() => {
    let cancelled = false;

    if (query.length > 2) {
      fetch(`/api/search?q=${query}`)
        .then(r => r.json())
        .then(data => {
          if (!cancelled) results = data;
        });
    }

    return () => {
      cancelled = true;
    };
  });
</script>

Option 4: Deferred Data Loading (Load Function + Client Fetch)

Load essential data on server, fetch supplementary data on client:

// +page.server.ts
export async function load() {
  // Critical data - needed for SSR
  return {
    post: await db.posts.findOne()
  };
}
<!-- +page.svelte -->
<script>
  import { onMount } from 'svelte';

  let { data } = $props();
  let comments = $state([]);

  onMount(async () => {
    // Non-critical data - loaded after page renders
    comments = await fetch(`/api/posts/${data.post.id}/comments`)
      .then(r => r.json());
  });
</script>

<article>
  <h1>{data.post.title}</h1>
  <p>{data.post.content}</p>
</article>

<section>
  <h2>Comments</h2>
  {#if comments.length === 0}
    <p>Loading comments...</p>
  {:else}
    {#each comments as comment}
      <div>{comment.text}</div>
    {/each}
  {/if}
</section>

Option 5: Streaming with Promises (Advanced)

Return unresolved promises from load functions:

// +page.server.ts
export async function load() {
  return {
    // Resolved immediately
    post: await db.posts.findOne(),

    // Streamed - page renders before this resolves
    comments: db.comments.findAll(), // Note: no await!
    relatedPosts: db.posts.findRelated()
  };
}
<script>
  let { data } = $props();
</script>

<article>
  <h1>{data.post.title}</h1>
</article>

{#await data.comments}
  <p>Loading comments...</p>
{:then comments}
  {#each comments as comment}
    <div>{comment.text}</div>
  {/each}
{:catch error}
  <p>Error loading comments</p>
{/await}

{#await data.relatedPosts}
  <p>Loading related posts...</p>
{:then posts}
  {#each posts as post}
    <a href="/blog/{post.slug}">{post.title}</a>
  {/each}
{/await}

Benefits:

  • Page renders immediately with available data
  • Slow queries don't block initial render
  • Progressive enhancement

Option 6: Intersection Observer (Lazy Loading)

<script>
  import { onMount } from 'svelte';

  let container: HTMLElement;
  let additionalData = $state([]);
  let loading = $state(false);

  onMount(() => {
    const observer = new IntersectionObserver(
      async (entries) => {
        if (entries[0].isIntersecting && !loading) {
          loading = true;
          additionalData = await fetch('/api/more-data').then(r => r.json());
          loading = false;
          observer.disconnect();
        }
      },
      { threshold: 0.1 }
    );

    observer.observe(container);

    return () => observer.disconnect();
  });
</script>

<div>
  <!-- Initial content -->
</div>

<div bind:this={container}>
  {#if loading}
    <p>Loading more...</p>
  {:else if additionalData.length > 0}
    {#each additionalData as item}
      <div>{item.title}</div>
    {/each}
  {/if}
</div>

Option 7: Using Actions (Server-Side Mutations)

For mutations/updates, use form actions instead of fetch:

// +page.server.ts
export const actions = {
  subscribe: async ({ request }) => {
    const data = await request.formData();
    await db.subscriptions.create({
      email: data.get('email')
    });

    return { success: true };
  }
};
<script>
  import { enhance } from '$app/forms';

  let form = $props();
</script>

<form method="POST" action="?/subscribe" use:enhance>
  <input name="email" type="email" />
  <button>Subscribe</button>
</form>

{#if form?.success}
  <p>Subscribed successfully!</p>
{/if}

Comparison: When to Use What

Use onMount when:

<script>
  onMount(async () => {
    // ✅ Browser-only APIs
    const position = await getCurrentPosition();

    // ✅ Analytics/tracking
    analytics.pageView();

    // ✅ Third-party scripts
    loadStripe();

    // ✅ Non-essential data
    const recommendations = await fetch('/api/recommendations').then(r => r.json());
  });
</script>

Use event handlers when:

<script>
  async function handleClick() {
    // ✅ User-triggered actions
    await fetch('/api/like', { method: 'POST' });
  }

  async function handleInput(e) {
    // ✅ Search/filter
    const results = await fetch(`/api/search?q=${e.target.value}`).then(r => r.json());
  }
</script>

<button onclick={handleClick}>Like</button>
<input oninput={handleInput} />

Use $effect when:

<script>
  let filters = $state({ category: 'all', price: 'any' });
  let products = $state([]);

  $effect(() => {
    // ✅ Reactive to multiple state changes
    const params = new URLSearchParams(filters);
    fetch(`/api/products?${params}`)
      .then(r => r.json())
      .then(data => products = data);
  });
</script>

Use load function + streaming when:

// ✅ Progressive page rendering
export async function load() {
  return {
    critical: await fetchCritical(),
    slow: fetchSlow() // No await - streams
  };
}

Advanced Pattern: Combining Approaches

<script>
  import { onMount } from 'svelte';
  import { invalidate } from '$app/navigation';

  let { data } = $props();
  let liveCount = $state(data.count);

  // Initial data from server
  // Live updates from client

  onMount(() => {
    const interval = setInterval(async () => {
      // Option A: Fetch directly
      const response = await fetch('/api/count');
      liveCount = await response.json();

      // Option B: Invalidate load function
      // await invalidate('count:live');
    }, 5000);

    return () => clearInterval(interval);
  });

  async function forceRefresh() {
    // Manual refresh via invalidation
    await invalidate('count:live');
  }
</script>

<div>
  <p>Count: {liveCount}</p>
  <button onclick={forceRefresh}>Refresh Now</button>
</div>

Best Practices

1. Prioritize load functions for initial data:

// ✅ Good - SSR, SEO-friendly
export async function load() {
  return { posts: await db.posts.findAll() };
}

2. Use onMount for supplementary data:

<script>
  let { data } = $props(); // From load

  onMount(async () => {
    // Load after page is interactive
    const analytics = await fetch('/api/analytics').then(r => r.json());
  });
</script>

3. Debounce frequent API calls:

<script>
  import { debounce } from '$lib/utils';

  let query = $state('');
  let results = $state([]);

  const search = debounce(async (q: string) => {
    results = await fetch(`/api/search?q=${q}`).then(r => r.json());
  }, 300);

  $effect(() => {
    if (query.length > 2) {
      search(query);
    }
  });
</script>

4. Handle loading and error states:

<script>
  let data = $state(null);
  let loading = $state(false);
  let error = $state(null);

  async function fetchData() {
    loading = true;
    error = null;

    try {
      data = await fetch('/api/data').then(r => r.json());
    } catch (e) {
      error = e.message;
    } finally {
      loading = false;
    }
  }
</script>

{#if loading}
  <p>Loading...</p>
{:else if error}
  <p>Error: {error}</p>
{:else if data}
  <p>{data.message}</p>
{/if}

Summary

You don't necessarily need onMount - it depends on your use case:

  • Initial/critical data: Use load functions
  • After page interactive: Use onMount
  • User interactions: Use event handlers
  • Reactive to state: Use $effect
  • Progressive loading: Use streaming promises
  • Mutations: Use form actions

The key is choosing the right tool for the job!

Does this clarify your options for making API calls?


Form Actions

Excellent question! Form actions are one of SvelteKit's most powerful features for handling server-side mutations. Let me explain comprehensively.

What Are Form Actions?

Form actions are server-side functions that handle form submissions. They run on the server and can perform mutations (create, update, delete data) without requiring JavaScript on the client.

Basic Structure

// routes/login/+page.server.ts
export const actions = {
  default: async ({ request, cookies }) => {
    const data = await request.formData();
    const email = data.get('email');
    const password = data.get('password');

    const user = await authenticateUser(email, password);

    if (user) {
      cookies.set('session', user.sessionId, { path: '/' });
      return { success: true };
    }

    return { success: false, error: 'Invalid credentials' };
  }
};
<!-- routes/login/+page.svelte -->
<script>
  let { form } = $props(); // Contains action response
</script>

<form method="POST">
  <input name="email" type="email" required />
  <input name="password" type="password" required />
  <button>Login</button>

  {#if form?.error}
    <p class="error">{form.error}</p>
  {/if}
</form>

Named vs Default Actions

Default Action

// +page.server.ts
export const actions = {
  default: async ({ request }) => {
    // Handles POST to the page URL
    const data = await request.formData();
    // Process...
    return { success: true };
  }
};
<!-- Form submits to default action -->
<form method="POST">
  <button>Submit</button>
</form>

Named Actions

// +page.server.ts
export const actions = {
  login: async ({ request, cookies }) => {
    const data = await request.formData();
    // Login logic
    return { success: true };
  },

  register: async ({ request }) => {
    const data = await request.formData();
    // Registration logic
    return { success: true };
  },

  resetPassword: async ({ request }) => {
    const data = await request.formData();
    // Password reset logic
    return { success: true };
  }
};
<!-- Specify action with ?/actionName -->
<form method="POST" action="?/login">
  <input name="email" />
  <button>Login</button>
</form>

<form method="POST" action="?/register">
  <input name="email" />
  <button>Register</button>
</form>

<form method="POST" action="?/resetPassword">
  <input name="email" />
  <button>Reset Password</button>
</form>

Progressive Enhancement with use:enhance

Without JavaScript, forms work as traditional HTML forms (full page reload). With use:enhance, you get SPA-like behavior:

Basic Enhancement

<script>
  import { enhance } from '$app/forms';
  let { form } = $props();
</script>

<form method="POST" use:enhance>
  <input name="email" />
  <button>Submit</button>
</form>

<!--
  With JS: Submits via fetch, no page reload
  Without JS: Traditional form submission, page reloads
-->

Custom Enhancement with Callbacks

<script>
  import { enhance } from '$app/forms';

  let loading = $state(false);
  let { form } = $props();
</script>

<form
  method="POST"
  use:enhance={() => {
    // Before submission
    loading = true;

    return async ({ result, update }) => {
      // After submission
      loading = false;

      if (result.type === 'success') {
        console.log('Form submitted successfully!');
      }

      // Apply default behavior (update $page.form)
      await update();
    };
  }}
>
  <input name="email" />
  <button disabled={loading}>
    {loading ? 'Submitting...' : 'Submit'}
  </button>
</form>

Complete Enhancement API

<script>
  import { enhance } from '$app/forms';
  import { invalidateAll } from '$app/navigation';

  let submitting = $state(false);
</script>

<form
  method="POST"
  action="?/create"
  use:enhance={({ formElement, formData, action, cancel, submitter }) => {
    // Called before submission
    console.log('Submitting to:', action.pathname);

    // Can modify formData
    formData.append('timestamp', Date.now().toString());

    // Can cancel submission
    if (!confirm('Are you sure?')) {
      cancel();
      return;
    }

    submitting = true;

    return async ({ result, update, formElement, formData }) => {
      // Called after response received
      submitting = false;

      if (result.type === 'success') {
        // Custom success handling
        showToast('Created successfully!');
        formElement.reset();

        // Invalidate data
        await invalidateAll();
      }

      if (result.type === 'failure') {
        // Custom error handling
        showToast('Error: ' + result.data?.message);
      }

      if (result.type === 'redirect') {
        // Will redirect automatically
        console.log('Redirecting to:', result.location);
      }

      // Apply default SvelteKit behavior
      await update();

      // Or customize further:
      // await update({ reset: false }); // Don't reset form
      // await update({ invalidateAll: false }); // Don't invalidate
    };
  }}
>
  <input name="title" />
  <button disabled={submitting}>
    {submitting ? 'Creating...' : 'Create'}
  </button>
</form>

Action Return Types

1. Return Data (Success)

export const actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    const post = await db.posts.create({
      title: data.get('title')
    });

    return { success: true, post };
  }
};
<script>
  let { form } = $props();
</script>

{#if form?.success}
  <p>Created: {form.post.title}</p>
{/if}

2. Return Failure with fail()

import { fail } from '@sveltejs/kit';

export const actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    const title = data.get('title');

    if (!title || title.length < 3) {
      return fail(400, {
        error: 'Title must be at least 3 characters',
        title // Return invalid value for form repopulation
      });
    }

    await db.posts.create({ title });
    return { success: true };
  }
};
<script>
  let { form } = $props();
</script>

<form method="POST" use:enhance>
  <input
    name="title"
    value={form?.title ?? ''}
  />

  {#if form?.error}
    <p class="error">{form.error}</p>
  {/if}

  <button>Create</button>
</form>

3. Redirect with redirect()

import { redirect } from '@sveltejs/kit';

export const actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    const post = await db.posts.create({
      title: data.get('title')
    });

    // Redirect after successful creation
    redirect(303, `/posts/${post.id}`);
  }
};

4. Error Handling

import { error, fail } from '@sveltejs/kit';

export const actions = {
  delete: async ({ request, locals }) => {
    if (!locals.user) {
      // Throws error, goes to error page
      error(401, 'Unauthorized');
    }

    const data = await request.formData();
    const id = data.get('id');

    const post = await db.posts.findOne({ id });

    if (!post) {
      // Returns failure, stays on page
      return fail(404, { error: 'Post not found' });
    }

    if (post.authorId !== locals.user.id) {
      error(403, 'Forbidden');
    }

    await db.posts.delete({ id });
    return { success: true };
  }
};

Complete CRUD Example

// routes/todos/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';

export async function load() {
  return {
    todos: await db.todos.findAll()
  };
}

export const actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    const text = data.get('text')?.toString();

    if (!text || text.length < 1) {
      return fail(400, { error: 'Text is required', text });
    }

    await db.todos.create({ text, done: false });
    return { success: true };
  },

  toggle: async ({ request }) => {
    const data = await request.formData();
    const id = data.get('id')?.toString();

    if (!id) {
      return fail(400, { error: 'ID is required' });
    }

    await db.todos.toggle(id);
    return { success: true };
  },

  delete: async ({ request }) => {
    const data = await request.formData();
    const id = data.get('id')?.toString();

    if (!id) {
      return fail(400, { error: 'ID is required' });
    }

    await db.todos.delete(id);
    return { success: true };
  }
};
<!-- routes/todos/+page.svelte -->
<script>
  import { enhance } from '$app/forms';
  let { data, form } = $props();
</script>

<!-- Create Form -->
<form method="POST" action="?/create" use:enhance>
  <input
    name="text"
    placeholder="New todo..."
    value={form?.text ?? ''}
  />
  <button>Add</button>

  {#if form?.error}
    <p class="error">{form.error}</p>
  {/if}
</form>

<!-- Todo List -->
{#each data.todos as todo}
  <div class="todo">
    <!-- Toggle Form -->
    <form method="POST" action="?/toggle" use:enhance>
      <input type="hidden" name="id" value={todo.id} />
      <button class="checkbox">
        {todo.done ? '' : ''}
      </button>
    </form>

    <span class:done={todo.done}>{todo.text}</span>

    <!-- Delete Form -->
    <form method="POST" action="?/delete" use:enhance>
      <input type="hidden" name="id" value={todo.id} />
      <button>Delete</button>
    </form>
  </div>
{/each}

<style>
  .done { text-decoration: line-through; }
</style>

Validation Patterns

Server-Side Validation

// lib/validation.ts
export function validateEmail(email: unknown): string | null {
  if (typeof email !== 'string') return 'Email is required';
  if (!email.includes('@')) return 'Invalid email format';
  return null;
}

export function validatePassword(password: unknown): string | null {
  if (typeof password !== 'string') return 'Password is required';
  if (password.length < 8) return 'Password must be at least 8 characters';
  return null;
}
// +page.server.ts
import { fail } from '@sveltejs/kit';
import { validateEmail, validatePassword } from '$lib/validation';

export const actions = {
  register: async ({ request }) => {
    const data = await request.formData();
    const email = data.get('email');
    const password = data.get('password');

    const errors: Record<string, string> = {};

    const emailError = validateEmail(email);
    if (emailError) errors.email = emailError;

    const passwordError = validatePassword(password);
    if (passwordError) errors.password = passwordError;

    if (Object.keys(errors).length > 0) {
      return fail(400, { errors, email });
    }

    await db.users.create({ email, password });
    return { success: true };
  }
};
<script>
  let { form } = $props();
</script>

<form method="POST" use:enhance>
  <div>
    <input
      name="email"
      type="email"
      value={form?.email ?? ''}
    />
    {#if form?.errors?.email}
      <p class="error">{form.errors.email}</p>
    {/if}
  </div>

  <div>
    <input name="password" type="password" />
    {#if form?.errors?.password}
      <p class="error">{form.errors.password}</p>
    {/if}
  </div>

  <button>Register</button>
</form>

File Uploads

// +page.server.ts
export const actions = {
  upload: async ({ request }) => {
    const data = await request.formData();
    const file = data.get('avatar') as File;

    if (!file || file.size === 0) {
      return fail(400, { error: 'File is required' });
    }

    if (file.size > 5 * 1024 * 1024) {
      return fail(400, { error: 'File must be less than 5MB' });
    }

    if (!file.type.startsWith('image/')) {
      return fail(400, { error: 'File must be an image' });
    }

    // Save file
    const buffer = await file.arrayBuffer();
    const filename = `${crypto.randomUUID()}-${file.name}`;
    await saveFile(filename, buffer);

    return { success: true, filename };
  }
};
<script>
  import { enhance } from '$app/forms';
  let { form } = $props();
</script>

<form
  method="POST"
  action="?/upload"
  enctype="multipart/form-data"
  use:enhance
>
  <input
    name="avatar"
    type="file"
    accept="image/*"
  />

  {#if form?.error}
    <p class="error">{form.error}</p>
  {/if}

  {#if form?.success}
    <p>Uploaded: {form.filename}</p>
  {/if}

  <button>Upload</button>
</form>

Important Behaviors

1. Actions Auto-Invalidate Load Functions

export async function load() {
  return { todos: await db.todos.findAll() };
}

export const actions = {
  create: async ({ request }) => {
    await db.todos.create({ text: data.get('text') });
    // Load function automatically re-runs after this!
    return { success: true };
  }
};

2. Multiple Forms, Same Action

{#each data.posts as post}
  <form method="POST" action="?/delete" use:enhance>
    <input type="hidden" name="id" value={post.id} />
    <button>Delete {post.title}</button>
  </form>
{/each}

3. Accessing Action Response

<script>
  let { form } = $props(); // Automatically populated after action
</script>

<!-- form contains the return value from the action -->
{#if form?.success}
  <p>Success!</p>
{/if}

4. Non-Form Submissions

You can programmatically submit to actions:

<script>
  async function deletePost(id: string) {
    const formData = new FormData();
    formData.append('id', id);

    const response = await fetch('?/delete', {
      method: 'POST',
      body: formData
    });

    const result = await response.json();
    console.log(result);
  }
</script>

<button onclick={() => deletePost('123')}>Delete</button>

Summary

Form Actions are for:

  • ✅ Server-side mutations (create, update, delete)
  • ✅ Progressive enhancement (works without JS)
  • ✅ Automatic load function invalidation
  • ✅ Built-in validation and error handling

Key points:

  • Actions run on the server only
  • Use fail() for validation errors (stays on page)
  • Use redirect() for successful mutations (navigate away)
  • Use use:enhance for SPA-like behavior
  • Access responses via let { form } = $props()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment