You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A comprehensive guide to component architecture and state management patterns in Svelte 5, focusing on the distinction between smart and presentational components.
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>
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.
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.
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.
classAppStore{// User stateuser=$state(null);isAuthenticated=$state.derived(()=>!!this.user);// UI statesidebarOpen=$state(false);theme=$state('light');// Notificationsnotifications=$state([]);// Actionslogin(userData){this.user=userData;}logout(){this.user=null;}toggleSidebar(){this.sidebarOpen=!this.sidebarOpen;}addNotification(message,type='info'){constid=Date.now();this.notifications.push({ id, message, type });// Auto remove after 5 secondssetTimeout(()=>{this.notifications=this.notifications.filter(n=>n.id!==id);},5000);}}exportconstappStore=newAppStore();
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.
exportconstapiStore=$state({userId: null,userCache: newMap()});// Derived user data that handles caching and loadingexportconstcurrentUser=$state.derived(async()=>{if(!apiStore.userId)returnnull;// Check cache firstif(apiStore.userCache.has(apiStore.userId)){returnapiStore.userCache.get(apiStore.userId);}// Fetch from APIconstresponse=awaitfetch(`/api/users/${apiStore.userId}`);constuser=awaitresponse.json();// Cache the resultapiStore.userCache.set(apiStore.userId,user);returnuser;});
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
letuserId=$state(null);letuserData=$state(null);$effect(()=>{if(!userId)return;// Side effect: sync with external systemconstcontroller=newAbortController();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 functionreturn()=>controller.abort();});
2. DOM Manipulation (when necessary)
letchartContainer=$state();letchartData=$state([]);letchartInstance=null;$effect(()=>{if(!chartContainer||!chartData.length)return;// Clean up previous chartif(chartInstance){chartInstance.destroy();}// Create new chart with external librarychartInstance=newChart(chartContainer,{type: 'line',data: chartData});return()=>{if(chartInstance){chartInstance.destroy();chartInstance=null;}};});
3. Browser API Synchronization
lettheme=$state('light');$effect(()=>{// Sync with localStoragelocalStorage.setItem('theme',theme);// Update CSS custom propertydocument.documentElement.setAttribute('data-theme',theme);});letwindowWidth=$state(0);$effect(()=>{if(typeofwindow==='undefined')return;functionupdateWidth(){windowWidth=window.innerWidth;}updateWidth();window.addEventListener('resize',updateWidth);return()=>window.removeEventListener('resize',updateWidth);});
// BADletfirstName=$state('John');letlastName=$state('Doe');letfullName=$state('');$effect(()=>{fullName=`${firstName}${lastName}`;// Use $state.derived instead!});// GOODletfirstName=$state('John');letlastName=$state('Doe');letfullName=$state.derived(()=>`${firstName}${lastName}`);
❌ Don't use effects for component communication
// BADletselectedItem=$state(null);$effect(()=>{if(selectedItem){// Don't use effects to trigger other state changesshowModal=true;loadItemDetails(selectedItem.id);}});// GOOD - Use functions/callbacks insteadfunctionhandleItemSelect(item){selectedItem=item;showModal=true;loadItemDetails(item.id);}
Best Practices
1. Always Clean Up
$effect(()=>{constinterval=setInterval(()=>{// Do something},1000);// Always return cleanup functionreturn()=>clearInterval(interval);});
letelement=$state();letelementHeight=$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 componentexportfunctioncreateGlobalEffects(){return$effect.root(()=>{// Global keyboard shortcuts$effect(()=>{functionhandleKeyboard(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 effectconsole.log('Effect triggered:',{ userId, preferences, theme });// Effect logicsyncUserSettings();});
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 creationconsole.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 registeredonMount(() => {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 -->
<buttononclick={() =>count++}>
{count}
</button>
Template Processing - DOM nodes created but not inserted
$effect.pre - Runs before DOM insertion (for measuring existing DOM)
DOM Update - Component inserted into DOM
$effect - Runs after DOM is updated
onMount - Runs after component is fully mounted
Subsequent Updates
beforeUpdate - Before any DOM changes
$effect.pre - Before DOM update (can read old DOM state)
DOM Update - Changes applied to DOM
$effect - After DOM is updated (can read new DOM state)
afterUpdate - After all updates complete
Effect Timing Examples
letelement;letwidth=$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 hooksonMount(()=>{// Component is fully rendered and in DOM// Good for: initializing third-party libraries, focus managementconsole.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 updatesbeforeUpdate(() => {if (scrollContainer) { savedScrollTop =scrollContainer.scrollTop; } });// Restore scroll position after DOM updates$effect(() => {if (scrollContainer &&items.length>0) {scrollContainer.scrollTop= savedScrollTop; } });onMount(() => {// Initialize with dataloadItems(); });
</script>
<divbind:this={scrollContainer} class="scroll-container">
{#eachitemsasitem}
<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 creationconsole.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 changeif (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 changesif (element) {console.log('After update - new count in DOM:', element.textContent); } });// 3. Lifecycle hooks are registeredonMount(() => {console.log('5. onMount - component mounted to DOM'); });onDestroy(() => {console.log('Component destroyed'); });
</script>
<!-- 2. Template is processed -->
<buttononclick={() =>count++}>
<spanbind: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 componentbeforeUpdate(() => {if (scrollContainer) { savedScrollTop =scrollContainer.scrollTop; } });// Runs after ANY update to the componentafterUpdate(() => {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
Script Execution - State and effects registered
Template Processing - DOM structure created
$effect.pre - Runs before DOM insertion (if dependencies exist)
DOM Update - Component inserted/updated
$effect - Runs after DOM changes
onMount - Component fully mounted
Subsequent Updates
State Change - Reactive state updated
$effect.pre - Before DOM reflects the change (only for relevant effects)
DOM Update - Changes applied
$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 -->
<scriptmodule>
// Runs ONCE when MyComponent.svelte is first importedconsole.log('Module script runs once');let moduleCounter =0;// This is shared across ALL instances of MyComponentexportfunctiongetNextId() {return++moduleCounter; }// Module-level constantsexportconstCOMPONENT_NAME='MyComponent';exportconstDEFAULT_CONFIG= { theme:'light', size:'medium' };
</script>
<script>
// Runs for EACH component instanceconsole.log('Instance script runs per component');let instanceId =getNextId(); // Each instance gets unique IDlet count =$state(0);// Can access module variablesconsole.log('Component name:', COMPONENT_NAME);
</script>
<div>
Instance #{instanceId}: {count}
<buttononclick={() =>count++}>+</button>
</div>
Practical Examples
1. Shared Utilities
<!-- DataTable.svelte -->
<scriptmodule>
// Shared formatters used by all DataTable instancesexportconstformatters= {currency: (value) =>`$${value.toFixed(2)}`,date: (value) =>newDate(value).toLocaleDateString(),percentage: (value) =>`${(value *100).toFixed(1)}%` };// Shared validationexportfunctionvalidateColumn(column) {returncolumn.key&&column.label; }
</script>
<script>
let { columns, data, formatter } =$props();// Each instance can use the shared formattersfunctionformatCell(value, column) {constfmt= formatters[column.type] || ((v) => v);returnfmt(value); }
</script>
2. Global State/Registry
<!-- Modal.svelte -->
<scriptmodule>
// Global modal registry - shared across all modal instancesconstopenModals=newSet();exportfunctiongetOpenModalCount() {returnopenModals.size; }// Prevent body scroll when any modal is openfunctionupdateBodyScroll() {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 -->
<scriptmodule>
// Chart types available to all Chart instancesexportconstCHART_TYPES= {LINE:'line',BAR:'bar',PIE:'pie' };// Default themesexportconstTHEMES= { light: { bg:'#fff', text:'#000' }, dark: { bg:'#000', text:'#fff' } };// Validation functionexportfunctionisValidChartType(type) {returnObject.values(CHART_TYPES).includes(type); }
</script>
<script>
let { type =CHART_TYPES.LINE, theme ='light', data } =$props();// Each chart instance uses the shared constantsif (!isValidChartType(type)) {thrownewError(`Invalid chart type: ${type}`); }
</script>
Important Characteristics
Module Script Execution Example
<!-- Counter.svelte -->
<scriptmodule>
let totalInstances =0;exportfunctiongetTotalInstances() { return totalInstances; }
</script>
<script>
totalInstances++; // Increments for each new Counter componentconsole.log('Total Counter instances:', totalInstances);
</script>
Module variables are shared
<!-- App.svelte -->
<script>
importCounterfrom'./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 proplet { value =$bindable('') } =$props();
</script>
<inputbind:value />
<script>
// Bindable with default valuelet { 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>
<inputbind:value={email} type="email" />
3. Multiple Bindable Props
<script>
let { x =$bindable(0), y =$bindable(0), dragging =$bindable(false) } =$props();functionhandleDrag(event) {if (dragging) { x =event.clientX; y =event.clientY; } }
</script>
<divstyle="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!
Understanding how SvelteKit handles server-side rendering requires examining the complete request lifecycle and how different components work together.
When a user visits a SvelteKit page, the request goes through four distinct phases:
Server-Side Request Handling - Hooks and load functions execute
Server Rendering - Components render to HTML
Client-Side Hydration - JavaScript takes over in the browser
Client-Side Navigation - Subsequent page changes
Phase 1: Server-Side Request Handling
1. Server Hooks Execute First
// This intercepts the request before anything elseexportasyncfunctionhandle({ event, resolve }){// Add user session, modify request, etc.event.locals.user=awaitauthenticateUser(event);constresponse=awaitresolve(event);returnresponse;}
exportasyncfunctionload({ data, fetch }){// 'data' is from +layout.server.ts// Runs on server during SSR, then on client for navigationreturn{
...data,settings: awaitfetch('/api/settings').then(r=>r.json())};}
4. Page Server Load
exportasyncfunctionload({ params, parent }){constpost=awaitdb.posts.findOne({slug: params.slug});return{
post // Server-only, won't be sent to client as code};}
5. Page Universal Load
exportasyncfunctionload({ data, parent }){// 'data' is from +page.server.tsconstlayoutData=awaitparent();// Gets all parent load datareturn{
...data,related: awaitfetchRelatedPosts(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>
{@renderchildren()}
<!-- +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>
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 secretsexportasyncfunctionload(){constdata=awaitdb.query('SELECT * FROM posts');return{posts: data};}// +page.ts - enhance with client-safe operationsexportasyncfunctionload({ data, fetch }){constanalytics=awaitfetch('/api/analytics').then(r=>r.json());return{
...data,// posts from server
analytics,// fetched universallytimestamp: Date.now()// added on whichever context runs};}
4. Progressive Enhancement
Universal loaders let you build features that work with or without JavaScript:
// +page.tsexportasyncfunctionload({ fetch, url }){constsearchTerm=url.searchParams.get('q');// Works on server (initial load) and client (search updates)constresults=awaitfetch(`/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 enoughexportasyncfunctionload(){constsecrets=awaitgetAPIKeys();return{data: awaitfetchWithSecrets(secrets)};}
B) You don't need data at all:
<!-- Just +page.svelte -->
<script>
let count =$state(0);
</script>
<buttononclick={() =>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.
exportasyncfunctionhandle({ event, resolve }){constresponse=awaitresolve(event,{transformPageChunk: ({ html })=>{// Inject analytics, modify HTML before sendingreturnhtml.replace('%analytics%',getAnalyticsScript());}});// Add security headersresponse.headers.set('X-Frame-Options','DENY');returnresponse;}
Setting event.locals
exportasyncfunctionhandle({ event, resolve }){// Make data available to ALL load functionsevent.locals.userAgent=event.request.headers.get('user-agent');event.locals.db=createDatabaseConnection();returnresolve(event);}
Server-Side Error Handling
exportasyncfunctionhandleError({ error, event, status, message }){// Log to external serviceawaitlogToSentry({
error,url: event.url.pathname,user: event.locals.user});// Return safe error to clientreturn{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.tsexportasyncfunctionhandleError({ error, event, status, message }){// Send to analytics/monitoringif(typeofwindow!=='undefined'){analytics.trackError({error: error.message,path: event.url.pathname,
status
});}return{message: 'Oops! Something went wrong.'};}
Navigation Lifecycle Hooks
exportasyncfunctionhandleFetch({ event, request, fetch }){// Modify fetch requests during client-side navigation// Add auth token to API callsif(request.url.startsWith('/api')){request.headers.set('Authorization',`Bearer ${getToken()}`);}returnfetch(request);}
Global Client Setup
// This pattern isn't directly in hooks but shows the conceptexportfunctionhandleError({ error, event }){// Initialize client-side services onceif(!window.__analyticsInitialized){initAnalytics();window.__analyticsInitialized=true;}return{message: 'Error occurred'};}
exportasyncfunctionhandle({ event, resolve }){// Skip auth for public routesconstpublicRoutes=['/login','/signup','/api/public'];constisPublic=publicRoutes.some(route=>event.url.pathname.startsWith(route));if(!isPublic){// Auth check}returnresolve(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.tsexportasyncfunctionload({ fetch }){// This DOES make a fetch call to the serverconstdata=awaitfetch('/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.tsexportasyncfunctionload(){constposts=awaitdb.query('SELECT * FROM posts');return{ posts };}// +page.tsexportasyncfunctionload({ 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.tsexportasyncfunctionload(){return{posts: awaitdb.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.tsexportasyncfunctionload({ fetch }){// This goes directly to the external API from the browserconstdata=awaitfetch('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.tsexportasyncfunctionload(){return{serverData: 'from database'};}// +page.tsexportasyncfunctionload({ data, fetch }){// 'data' from +page.server.ts arrives automatically// SvelteKit makes an internal request to get itconstapiData=awaitfetch('/api/posts').then(r=>r.json());// ^ This is an explicit fetch YOU wrotereturn{
...data,
apiData
};}
What actually happens on client navigation:
SvelteKit sees you need +page.server.ts data
It makes an internal request: GET /blog/[slug]/__data.json
This special endpoint runs your server load function and returns JSON
Your +page.ts receives this as data
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.tsexportasyncfunctionload({ params }){// Database access (server-only)constproduct=awaitdb.products.findById(params.id);return{ product };}// routes/products/[id]/+page.tsexportasyncfunctionload({ data, fetch, params }){// data.product is automatically fetched from server during navigation// Explicit fetch calls you write:const[reviews,relatedProducts]=awaitPromise.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};}
Fetches /api/products/123/reviews → your API route
Fetches /api/products/123/related → your API route
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)exportasyncfunctionload({ fetch }){// These headers are automatically forwarded from the original request:// - cookie// - authorization// - x-sveltekit-*constdata=awaitfetch('/api/posts').then(r=>r.json());return{ data };}
Custom headers need explicit passing:
// +page.server.tsexportasyncfunctionload({ fetch, request }){// Custom header from client requestconstcustomAuth=request.headers.get('x-custom-auth');// You MUST pass it explicitly:constdata=awaitfetch('/api/posts',{headers: {'x-custom-auth': customAuth}}).then(r=>r.json());return{ data };}
Common pattern for API keys or custom auth:
// +page.server.tsexportasyncfunctionload({ fetch, request, cookies }){constapiKey=request.headers.get('x-api-key');constcsrfToken=cookies.get('csrf_token');constdata=awaitfetch('/api/protected',{headers: {'x-api-key': apiKey||'','x-csrf-token': csrfToken||''}}).then(r=>r.json());return{ data };}
// routes/api/posts/+server.tsexportasyncfunctionGET({ locals }){console.log('📝 API route running');console.log('User from locals:',locals.user);returnjson({posts: awaitdb.posts.findAll()});}
// routes/blog/+page.server.tsexportasyncfunctionload({ fetch }){console.log('📄 Page server load running');// This goes through the hook!constposts=awaitfetch('/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.tsexportasyncfunctionload({ fetch, locals }){// Even though we're on the same server, the hook will check auth// ❌ This will fail if your hook requires auth:constdata=awaitfetch('/api/protected').then(r=>r.json());// ✅ Must include auth header:constdata=awaitfetch('/api/protected',{headers: {'authorization': `Bearer ${locals.sessionToken}`}}).then(r=>r.json());return{ data };}
2. Hooks run multiple times during SSR:
// hooks.server.tsexportasyncfunctionhandle({ event, resolve }){console.log('Hook ran for:',event.url.pathname);returnresolve(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.tsexportasyncfunctiongetPosts(){returndb.posts.findAll();}// routes/api/posts/+server.tsimport{getPosts}from'$lib/db';exportasyncfunctionGET(){returnjson({posts: awaitgetPosts()});}// routes/blog/+page.server.tsimport{getPosts}from'$lib/db';exportasyncfunctionload(){// Direct DB call - no HTTP, no hooks, faster!return{posts: awaitgetPosts()};}
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.tsexportasyncfunctiongetPosts(userId?: string){if(userId){returndb.posts.where('userId',userId).findAll();}returndb.posts.findAll();}// routes/api/posts/+server.tsimport{getPosts}from'$lib/posts.server';exportasyncfunctionGET({ locals }){// Hook already validated auth and set locals.userreturnjson({posts: awaitgetPosts(locals.user?.id)});}// routes/blog/+page.server.tsimport{getPosts}from'$lib/posts.server';exportasyncfunctionload({ locals }){// Skip the HTTP layer entirely, direct callreturn{posts: awaitgetPosts(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.tsexportasyncfunctionload(){awaitnewPromise(resolve=>setTimeout(resolve,100));// 100msreturn{user: {name: 'John'}};}// routes/blog/+page.server.tsexportasyncfunctionload({ parent }){awaitparent();// Must wait for layout to finishawaitnewPromise(resolve=>setTimeout(resolve,100));// 100msreturn{posts: []};}// Total time: ~200ms (serial execution)
When to Use parent()
✅ Use parent() when you need parent data:
// routes/+layout.server.tsexportasyncfunctionload(){return{userId: 123};}// routes/profile/+page.server.tsexportasyncfunctionload({ parent }){const{ userId }=awaitparent();// Need userId from parent to fetch user profileconstprofile=awaitdb.profiles.findOne({ userId });return{ profile };}
✅ Use when child depends on parent's computation:
// routes/+layout.server.tsexportasyncfunctionload(){consttenant=awaitidentifyTenant();return{ tenant };}// routes/dashboard/+page.server.tsexportasyncfunctionload({ parent }){const{ tenant }=awaitparent();// Dashboard data is tenant-specificconstdata=awaitdb.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.tsexportasyncfunctionload(){awaitfetchSomeData();// slow operationreturn{layoutData: 'something'};}// routes/blog/+page.server.tsexportasyncfunctionload({ parent }){awaitparent();// ❌ Unnecessary wait!// Not using parent data at allconstposts=awaitdb.posts.findAll();return{ posts };}// Better:exportasyncfunctionload(){// ✅ Runs in parallel with layoutconstposts=awaitdb.posts.findAll();return{ posts };}
Where parent() Can Be Used
1. In Page Load Functions (both server and universal):
// routes/+layout.server.tsexportasyncfunctionload(){const[user,settings]=awaitPromise.all([db.users.find(),db.settings.find()]);return{ user, settings };}// routes/blog/+page.server.tsexportasyncfunctionload({ parent }){// Fetch posts in parallel with parentconst[parentData,posts]=awaitPromise.all([parent(),db.posts.findAll()]);// Use user from parent for filteringconstfilteredPosts=posts.filter(p=>p.authorId===parentData.user.id);return{ ...parentData,posts: filteredPosts};}
Pattern 2: Conditional parent access:
// routes/dashboard/+page.server.tsexportasyncfunctionload({ parent, url }){constneedsUserData=url.searchParams.has('user');if(needsUserData){const{ user }=awaitparent();// Use user datareturn{data: awaitfetchUserSpecificData(user.id)};}// No parent needed, run in parallelreturn{data: awaitfetchGeneralData()};}
Pattern 3: Extract only what you need early:
// routes/blog/+page.server.tsexportasyncfunctionload({ parent }){// Start fetching posts immediatelyconstpostsPromise=db.posts.findAll();// Get parent dataconst{ user }=awaitparent();// Wait for postsconstposts=awaitpostsPromise;// Filter with user datareturn{posts: posts.filter(p=>canUserView(p,user))};}
Common Mistakes
❌ Mistake 1: Unnecessary awaiting:
exportasyncfunctionload({ parent }){constparentData=awaitparent();// Not using parentData at all!return{posts: awaitdb.posts.findAll()};}
❌ Mistake 2: Double awaiting:
exportasyncfunctionload({ parent, data }){constparentData=awaitparent();// data already contains parent server load data!// No need for parent() if you just want server datareturn{ ...data,extra: 'stuff'};}
❌ Mistake 3: Breaking parallelism unnecessarily:
exportasyncfunctionload({ parent }){awaitparent();// Wait even though we don't need the data// These could run in parallel with parentconsta=awaitfetchA();constb=awaitfetchB();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
<!-- +page.svelte -->
<script>
import { invalidate } from'$app/navigation';let { data } =$props();asyncfunctionrefreshPosts() {// Re-runs ALL load functions that depend on 'posts:list'awaitinvalidate('posts:list'); }
</script>
<buttononclick={refreshPosts}>Refresh Posts</button>
5. Manual Invalidation with invalidateAll()
<script>
import { invalidateAll } from'$app/navigation';asyncfunctionrefreshEverything() {// Re-runs ALL load functions on the current pageawaitinvalidateAll(); }
</script>
<buttononclick={refreshEverything}>Refresh Everything</button>
Understanding depends(), invalidate(), and invalidateAll()
depends() - Register Dependencies
Allows you to create custom dependency keys:
// routes/posts/+page.server.tsexportasyncfunctionload({ depends }){depends('app:posts');depends('app:user');const[posts,user]=awaitPromise.all([db.posts.findAll(),db.users.getCurrent()]);return{ posts, user };}// routes/comments/+page.server.tsexportasyncfunctionload({ depends }){depends('app:posts');// Also depends on postsreturn{comments: awaitdb.comments.findAll()};}
invalidate(dependency) - Selective Invalidation
Re-runs only load functions that depend on the specified key:
<script>
import { invalidate } from'$app/navigation';asyncfunctionupdatePosts() {awaitapi.updatePost();// Only re-runs load functions with depends('app:posts')awaitinvalidate('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 { invalidate } from'$app/navigation';asyncfunctionrefreshMetrics() {// Only re-fetches /api/metrics, not /api/notificationsawaitinvalidate('/api/metrics'); }asyncfunctionrefreshNotifications() {awaitinvalidate('/api/notifications'); }asyncfunctionrefreshBoth() {// Can invalidate multiple URLsawaitPromise.all([invalidate('/api/metrics'),invalidate('/api/notifications') ]);// Or just use invalidateAll() }
</script>
Important Behaviors
1. Automatic URL Dependency Tracking
exportasyncfunctionload({ fetch }){// SvelteKit automatically tracks this URL as a dependencyconstdata=awaitfetch('/api/data').then(r=>r.json());return{ data };}
You can invalidate by URL without using depends():
awaitinvalidate('/api/data');
2. Form Actions Auto-invalidate
exportconstactions={update: async()=>{awaitdb.update();// No need to manually invalidate!// Load functions re-run automatically}};
3. Invalidation is Scoped to Current Page
// On /blog pageawaitinvalidate('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.tsexportasyncfunctionload({ depends }){depends('app:auth');return{user: awaitgetUser()};}// +page.server.ts - doesn't need depends()exportasyncfunctionload({ parent }){const{ user }=awaitparent();return{data: awaitgetData(user.id)};}
awaitinvalidate('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 invalidateasyncfunctionincrement() { count++;awaitinvalidateAll(); // Unnecessary server round-trip! }// ✅ Good - just update statefunctionincrement() { count++; }
</script>
❌ Don't invalidate when you can use reactive state:
<script>
let { data } =$props();// ❌ Badasyncfunctionfilter() {awaitinvalidate('products:list'); // Unnecessary }// ✅ Good - filter client-sidelet 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:
<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 clientonMount(() => {constinterval=setInterval(async () => {// Option A: Fetch directlyconstresponse=awaitfetch('/api/count'); liveCount =awaitresponse.json();// Option B: Invalidate load function// await invalidate('count:live'); }, 5000);return () =>clearInterval(interval); });asyncfunctionforceRefresh() {// Manual refresh via invalidationawaitinvalidate('count:live'); }
</script>
<div>
<p>Count: {liveCount}</p>
<buttononclick={forceRefresh}>Refresh Now</button>
</div>
Best Practices
1. Prioritize load functions for initial data:
// ✅ Good - SSR, SEO-friendlyexportasyncfunctionload(){return{posts: awaitdb.posts.findAll()};}
2. Use onMount for supplementary data:
<script>
let { data } =$props(); // From loadonMount(async () => {// Load after page is interactiveconstanalytics=awaitfetch('/api/analytics').then(r=>r.json()); });
</script>
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.
// +page.server.tsexportconstactions={default: async({ request })=>{// Handles POST to the page URLconstdata=awaitrequest.formData();// Process...return{success: true};}};
<!-- Form submits to default action -->
<formmethod="POST">
<button>Submit</button>
</form>
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>
<formmethod="POST"use:enhance>
<inputname="email" />
<button>Submit</button>
</form>
<!-- With JS: Submits via fetch, no page reload Without JS: Traditional form submission, page reloads-->
exportconstactions={create: async({ request })=>{constdata=awaitrequest.formData();constpost=awaitdb.posts.create({title: data.get('title')});return{success: true, post };}};
<script>
let { form } =$props();
</script>
{#ifform?.success}
<p>Created: {form.post.title}</p>
{/if}
2. Return Failure with fail()
import{fail}from'@sveltejs/kit';exportconstactions={create: async({ request })=>{constdata=awaitrequest.formData();consttitle=data.get('title');if(!title||title.length<3){returnfail(400,{error: 'Title must be at least 3 characters',
title // Return invalid value for form repopulation});}awaitdb.posts.create({ title });return{success: true};}};
<script>
let { form } =$props();
</script>
<formmethod="POST"use:enhance>
<inputname="title"value={form?.title??''}
/>
{#ifform?.error}
<pclass="error">{form.error}</p>
{/if}
<button>Create</button>
</form>
3. Redirect with redirect()
import{redirect}from'@sveltejs/kit';exportconstactions={create: async({ request })=>{constdata=awaitrequest.formData();constpost=awaitdb.posts.create({title: data.get('title')});// Redirect after successful creationredirect(303,`/posts/${post.id}`);}};
4. Error Handling
import{error,fail}from'@sveltejs/kit';exportconstactions={delete: async({ request, locals })=>{if(!locals.user){// Throws error, goes to error pageerror(401,'Unauthorized');}constdata=awaitrequest.formData();constid=data.get('id');constpost=awaitdb.posts.findOne({ id });if(!post){// Returns failure, stays on pagereturnfail(404,{error: 'Post not found'});}if(post.authorId!==locals.user.id){error(403,'Forbidden');}awaitdb.posts.delete({ id });return{success: true};}};
Complete CRUD Example
// routes/todos/+page.server.tsimport{fail,redirect}from'@sveltejs/kit';exportasyncfunctionload(){return{todos: awaitdb.todos.findAll()};}exportconstactions={create: async({ request })=>{constdata=awaitrequest.formData();consttext=data.get('text')?.toString();if(!text||text.length<1){returnfail(400,{error: 'Text is required', text });}awaitdb.todos.create({ text,done: false});return{success: true};},toggle: async({ request })=>{constdata=awaitrequest.formData();constid=data.get('id')?.toString();if(!id){returnfail(400,{error: 'ID is required'});}awaitdb.todos.toggle(id);return{success: true};},delete: async({ request })=>{constdata=awaitrequest.formData();constid=data.get('id')?.toString();if(!id){returnfail(400,{error: 'ID is required'});}awaitdb.todos.delete(id);return{success: true};}};
// lib/validation.tsexportfunctionvalidateEmail(email: unknown): string|null{if(typeofemail!=='string')return'Email is required';if(!email.includes('@'))return'Invalid email format';returnnull;}exportfunctionvalidatePassword(password: unknown): string|null{if(typeofpassword!=='string')return'Password is required';if(password.length<8)return'Password must be at least 8 characters';returnnull;}
// +page.server.tsexportconstactions={upload: async({ request })=>{constdata=awaitrequest.formData();constfile=data.get('avatar')asFile;if(!file||file.size===0){returnfail(400,{error: 'File is required'});}if(file.size>5*1024*1024){returnfail(400,{error: 'File must be less than 5MB'});}if(!file.type.startsWith('image/')){returnfail(400,{error: 'File must be an image'});}// Save fileconstbuffer=awaitfile.arrayBuffer();constfilename=`${crypto.randomUUID()}-${file.name}`;awaitsaveFile(filename,buffer);return{success: true, filename };}};
<script>
let { form } =$props(); // Automatically populated after action
</script>
<!-- form contains the return value from the action -->
{#ifform?.success}
<p>Success!</p>
{/if}