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
This document explains how SvelteKit components work together to serve requests, covering SSR (Server-Side Rendering), client-side navigation, data loading patterns, and advanced topics.
Runs in the browser only. Limited exports available:
Export
Purpose
handleError
Custom client-side error handling
init
Runs once when app starts in browser
Note: There is NO handle function for client hooks - you cannot intercept client-side navigation like you can server requests.
// src/hooks.client.tsimporttype{HandleClientError}from'@sveltejs/kit'exportconsthandleError: HandleClientError=({ error, event })=>{Sentry.captureException(error)return{message: 'Something went wrong'}}exportasyncfunctioninit(){// Runs once when app hydrates in browser// Be careful - this delays hydration}
hooks.ts (Universal Hooks)
Runs on both server and client. Available exports:
Export
Purpose
reroute
Rewrite URL paths before routing (e.g., i18n URL mapping)
transport
Custom serialization for passing data across server/client boundary
Does NOT change the browser URL, only internal routing
Results are cached per unique URL on the client
Can be async (since v2.18) but should be fast
Chaining Handle Functions with sequence
When you need multiple handle functions (e.g., authentication, logging, i18n), use the sequence helper to chain them together. Each handler runs in order, and each can modify event.locals or short-circuit the chain.
// src/hooks.server.tsimport{sequence}from'@sveltejs/kit/hooks'importtype{Handle}from'@sveltejs/kit'// 1. Logging middleware - runs firstconstlogger: Handle=async({ event, resolve })=>{conststart=Date.now()constresponse=awaitresolve(event)constduration=Date.now()-startconsole.log(`${event.request.method}${event.url.pathname} - ${duration}ms`)returnresponse}// 2. Authentication middlewareconstauth: Handle=async({ event, resolve })=>{constsessionId=event.cookies.get('session')if(sessionId){event.locals.user=awaitgetUserFromSession(sessionId)}returnresolve(event)}// 3. Authorization middleware - can short-circuitconstauthorize: Handle=async({ event, resolve })=>{if(event.url.pathname.startsWith('/admin')&&!event.locals.user?.isAdmin){returnnewResponse('Forbidden',{status: 403})}returnresolve(event)}// 4. i18n middlewareconsti18n: Handle=async({ event, resolve })=>{constlang=event.cookies.get('lang')||'en'event.locals.lang=langreturnresolve(event,{transformPageChunk: ({ html })=>html.replace('%lang%',lang)})}// Chain them together - order matters!exportconsthandle=sequence(logger,auth,authorize,i18n)
Execution Flow:
Request arrives
│
▼
┌─────────────┐
│ logger │ ─── logs start time
└─────────────┘
│
▼
┌─────────────┐
│ auth │ ─── sets event.locals.user
└─────────────┘
│
▼
┌─────────────┐
│ authorize │ ─── can return 403 (short-circuit)
└─────────────┘
│
▼
┌─────────────┐
│ i18n │ ─── sets event.locals.lang, transforms HTML
└─────────────┘
│
▼
resolve() ─── SvelteKit handles the request
│
▼
Response flows back through each handler (for logging, headers, etc.)
Key Points:
Each handler MUST call resolve(event) to continue the chain (or return a Response to short-circuit)
Handlers run in the order passed to sequence()
Each handler can modify event.locals - changes are visible to subsequent handlers
The resolve() options (like transformPageChunk) can be used in any handler
Returning a Response directly (without calling resolve) short-circuits the chain
Common Use Cases:
Handler
Purpose
Logger
Request timing, access logs
Auth
Parse session, set event.locals.user
Authorize
Check permissions, return 403/401 if unauthorized
i18n
Set language, transform HTML
CSRF
Validate CSRF tokens on mutations
Rate Limiting
Track requests, return 429 if exceeded
Feature Flags
Set event.locals.features based on user/config
3. Request Flows
Initial SSR Request Flow
When a user first visits /dashboard (full page load, e.g., typing URL or refresh):
┌─────────────────────────────────────────────────────────────────────────────┐
│ BROWSER → SERVER REQUEST │
│ GET /dashboard │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
╔═════════════════════════════════════════════════════════════════════════════╗
║ SERVER ║
╠═════════════════════════════════════════════════════════════════════════════╣
║ ║
║ 1. hooks.ts → reroute() [SERVER] ║
║ • Runs FIRST, can rewrite URL paths ║
║ │ ║
║ ▼ ║
║ 2. hooks.server.ts → handle() [SERVER ONLY] ║
║ • Runs for EVERY server request ║
║ • Can set event.locals, modify request/response ║
║ │ ║
║ ▼ ║
║ 3. ROUTE MATCHING [SERVER] ║
║ • SvelteKit matches URL to route ║
║ • Identifies all layouts in hierarchy ║
║ │ ║
║ ▼ ║
║ 4. SERVER LOAD FUNCTIONS [SERVER ONLY] (parallel by default) ║
║ • +layout.server.ts (root) → +layout.server.ts (nested) → +page.server.ts║
║ │ ║
║ ▼ ║
║ 5. UNIVERSAL LOAD FUNCTIONS [SERVER DURING SSR] ║
║ • +layout.ts → +page.ts (receive server data via `data` prop) ║
║ │ ║
║ ▼ ║
║ 6. COMPONENT RENDERING [SERVER - SSR] ║
║ • Components render to HTML string ║
║ • onMount() and $effect() do NOT run on server ║
║ │ ║
║ ▼ ║
║ 7. HTML RESPONSE [SERVER → CLIENT] ║
║ • HTML + serialized data + JS bundles sent to browser ║
║ ║
╚══════════════════════════════════════════════════════════════════════════════╝
│
▼
╔══════════════════════════════════════════════════════════════════════════════╗
║ BROWSER (CLIENT) ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ 8. HYDRATION [CLIENT] ║
║ • Browser receives HTML (immediately visible) ║
║ • JS bundles load and execute ║
║ • hooks.client.ts → init() runs (if defined) ║
║ • Svelte "hydrates" - attaches event listeners to existing DOM ║
║ • Universal load functions run again (reuse SSR fetch responses) ║
║ • onMount() callbacks execute, $effect() starts tracking ║
║ • Page is now interactive ║
║ ║
╚══════════════════════════════════════════════════════════════════════════════╝
Client-Side Navigation Flow
When user clicks a link to /dashboard/settings (after initial load):
┌─────────────────────────────────────────────────────────────────────────────┐
│ USER CLICKS <a href="/dashboard/settings"> │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
╔══════════════════════════════════════════════════════════════════════════════╗
║ BROWSER (CLIENT) ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ 1. hooks.ts → reroute() [CLIENT] ║
║ • Same reroute logic runs client-side (cached per URL) ║
║ │ ║
║ ▼ ║
║ 2. ROUTE MATCHING [CLIENT] ║
║ • Determines which load functions need to rerun ║
║ • Unchanged layouts are REUSED (not re-fetched) ║
║ ║
╚══════════════════════════════════════════════════════════════════════════════╝
│
▼
╔══════════════════════════════════════════════════════════════════════════════╗
║ SERVER ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ 3a. SERVER LOAD FUNCTIONS [SERVER] ║
║ • Browser makes fetch request to __data.json endpoint ║
║ • hooks.server.ts → handle() runs for this request ║
║ • Server runs load functions, returns serialized JSON ║
║ • Multiple server loads batched into single request ║
║ ║
╚══════════════════════════════════════════════════════════════════════════════╝
│
▼
╔══════════════════════════════════════════════════════════════════════════════╗
║ BROWSER (CLIENT) ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ ║
║ 3b. UNIVERSAL LOAD FUNCTIONS [CLIENT] ║
║ • Run DIRECTLY in browser (not on server) ║
║ • Receive server data via `data` property ║
║ │ ║
║ ▼ ║
║ 4. COMPONENT UPDATE [CLIENT] ║
║ • Unchanged layouts PRESERVED (no re-render) ║
║ • Only new/changed components render ║
║ • onMount() runs for NEW components only ║
║ • URL updates in browser (History API) ║
║ ║
╚══════════════════════════════════════════════════════════════════════════════╝
SvelteKit provides a special fetch function in load functions with intelligent behavior:
Feature
Description
Direct invocation during SSR
Internal API routes called directly without HTTP request
Credential preservation
Automatically forwards cookies and authorization headers for same-origin
Response deduplication
During hydration, reuses responses from SSR (no duplicate requests)
Relative URL support
Works with relative URLs on the server (native fetch requires absolute)
Important: Even when fetch calls an internal endpoint directly during SSR (without HTTP), the request still passes through hooks.server.ts → handle().
┌─────────────────────────────────────────────────────────────────────────────┐
│ During SSR (Server) │
│ ───────────────────────────────────────────────────────────────────────── │
│ +page.ts calls fetch('/api/dashboard') │
│ │ │
│ ▼ │
│ SvelteKit intercepts the fetch: │
│ • NO HTTP request is made │
│ • Directly invokes /api/dashboard/+server.ts handler │
│ • STILL goes through hooks.server.ts → handle() │
│ • Credentials (cookies) are preserved │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ During Client Navigation (Browser) │
│ ───────────────────────────────────────────────────────────────────────── │
│ +page.ts calls fetch('/api/dashboard') │
│ │ │
│ ▼ │
│ Normal HTTP request to server: │
│ • Request goes over the network │
│ • hooks.server.ts → handle() runs on server │
│ • /api/dashboard/+server.ts handler executes │
└─────────────────────────────────────────────────────────────────────────────┘
Cookie Handling:
// If app is on my-domain.com:fetch('/api/data')// ✅ Cookies forwarded (same origin)fetch('https://api.my-domain.com/data')// ✅ Cookies forwarded (subdomain)fetch('https://other-domain.com/data')// ❌ Cookies NOT forwarded
Parallel vs Sequential Execution
By default, SvelteKit runs all load functions in parallel. Use await parent() for sequential execution.
// ❌ BAD: Creates waterfallexportasyncfunctionload({ params, parent }){constparentData=awaitparent()// Waits for parentconstdata=awaitgetData(params)// Then fetches datareturn{ ...parentData, data }}// ✅ GOOD: Parallel executionexportasyncfunctionload({ params, parent }){const[parentData,data]=awaitPromise.all([parent(),getData(params)// Starts immediately])return{ ...parentData, data }}// ✅ ALSO GOOD: If you don't need parent dataexportasyncfunctionload({ params }){constdata=awaitgetData(params)return{ data }}
Use Case
Recommendation
Need data from parent layout
Use await parent()
Auth check before fetching
Use await parent() to get user first
Independent data fetching
Don't use parent() - let it run in parallel
Need parent data + own data
Use Promise.all([parent(), getData()])
Load Function Execution Order
During SSR (all steps on SERVER):
1. +layout.server.ts (root) ─┐
2. +layout.server.ts (nested) │ Server loads (PARALLEL unless await parent())
3. +page.server.ts ─┘
│
▼
4. +layout.ts (root) ─┐
5. +layout.ts (nested) │ Universal loads (on SERVER during SSR)
6. +page.ts ─┘
│
▼
7. Components render with merged data
During Client Navigation:
1-3. Server loads run on SERVER via fetch (batched into single request)
│ (JSON response)
▼
4-6. Universal loads run in BROWSER
│
▼
7. Components update in BROWSER
When Load Functions Rerun
Trigger
Server Load
Universal Load
Initial page load (SSR)
✅ Server
✅ Server, then ✅ Client (hydration)
Client navigation
✅ Via fetch
✅ Client
params change
✅ If depends on params
✅ If depends on params
url.searchParams change
✅ If accessed
✅ If accessed
invalidate(url)
✅ If depends on url
✅ If depends on url
invalidateAll()
✅ Always
✅ Always
Parent load reruns
✅ If calls parent()
✅ If calls parent()
5. Page Options
ssr, csr, prerender Flags
Option
Default
Description
ssr
true
Whether to server-render the page
csr
true
Whether to load the SvelteKit client (enables hydration and navigation)
prerender
false
Whether to generate static HTML at build time
trailingSlash
'never'
How to handle trailing slashes in URLs
// +page.ts or +layout.tsexportconstssr=falseexportconstcsr=trueexportconstprerender=false
ssr = false (SPA Mode):
Server sends empty HTML shell, all rendering in browser
Server load functions STILL RUN (data serialized to client)
No SEO - search engines see empty page
Slower First Contentful Paint
Good for: Admin dashboards, authenticated apps
csr = false (No JavaScript):
No JavaScript shipped to client
Page is purely static HTML
Every navigation is a full page load
No interactive components
Good for: Static content, documentation, blogs
prerender = true (Static Generation):
HTML generated at build time, not request time
hooks.server.ts → handle() does NOT run for prerendered pages
Good for: Marketing pages, documentation, landing pages
Combining Options:
Combination
Result
ssr: true, csr: true
Default - full SSR with hydration
ssr: false, csr: true
SPA mode - client-only rendering
ssr: true, csr: false
Static HTML - no JavaScript
ssr: false, csr: false
❌ Error - nothing would render
prerender: true, ssr: true
Static HTML at build time, with hydration
prerender: true, csr: false
Fully static pages (no JS)
Decision Guide
Need SEO?
│
┌──────────┴──────────┐
│ │
YES NO
│ │
▼ ▼
Content changes ssr = false
frequently? (SPA mode)
│
┌──────┴──────┐
│ │
YES NO
│ │
▼ ▼
ssr = true prerender = true
(default) (static generation)
│ │
▼ ▼
Need Need
interactivity? interactivity?
│ │
YES YES → prerender + csr (default)
│ NO → prerender + csr = false
▼
csr = true
(default)
Setting Options in Layouts (Inheritance):
// src/routes/(marketing)/+layout.ts// All pages under (marketing) will be prerenderedexportconstprerender=true// src/routes/(app)/+layout.ts// All pages under (app) will be SPA modeexportconstssr=false
Child pages can override parent layout options.
6. Remote Functions (Experimental)
SvelteKit 2.27 introduced remote functions as an experimental feature providing type-safe RPC-style communication between client and server.
What Are Remote Functions
Remote functions are declared in .remote.ts files and can be called like regular functions anywhere in your app, but always execute on the server. On the client, they're transformed into fetch wrappers.
// src/routes/blog/data.remote.tsimport{command,form,prerender,query}from'$app/server'import*asdbfrom'$lib/server/database'import*asvfrom'valibot'// Query: Read dynamic dataexportconstgetPosts=query(async()=>{returnawaitdb.sql`SELECT * FROM posts`})// Query with validated argumentexportconstgetPost=query(v.string(),async(slug)=>{returnawaitdb.sql`SELECT * FROM posts WHERE slug = ${slug}`})
Four Types of Remote Functions
Type
Purpose
Progressive Enhancement
Use Case
query
Read dynamic data
N/A
Fetching posts, user data
form
Write data via forms
✅ Works without JS
Creating/updating records
command
Write data programmatically
❌ Requires JS
Like buttons, quick actions
prerender
Read static data at build time
N/A
Data that changes per deployment
Remote Functions vs Load Functions
┌─────────────────────────────────────────────────────────────────────────────┐
│ LOAD FUNCTIONS (Traditional) │
└─────────────────────────────────────────────────────────────────────────────┘
+page.server.ts +page.svelte
┌─────────────────────┐ ┌─────────────────────┐
│ export async function│ │ let { data } = $props()│
│ load() { │ ──── data ────► │ // Use data.posts │
│ return { posts } │ │ │
│ } │ │ │
└─────────────────────┘ └─────────────────────┘
• Data defined at route boundary
• Flows through data prop
• Requires $types imports for TypeScript
┌─────────────────────────────────────────────────────────────────────────────┐
│ REMOTE FUNCTIONS (New) │
└─────────────────────────────────────────────────────────────────────────────┘
data.remote.ts PostList.svelte
┌─────────────────────┐ ┌─────────────────────┐
│ export const getPosts│ │ import { getPosts } │
│ = query(async () => │ ◄── import ─── │ from './data.remote'│
│ db.getPosts() │ │ const posts = │
│ ) │ │ await getPosts() │
└─────────────────────┘ └─────────────────────┘
• Data fetched where needed
• Direct function calls
• Native TypeScript (no generated types)
Request Flow with Remote Functions:
During SSR [SERVER]:
Component calls getPosts() → Executes directly on server → Data in HTML payload
During Client Navigation [CLIENT → SERVER]:
Component calls getPosts() → HTTP request to generated endpoint → Server executes → Response
Query Caching: Multiple components calling the same query share a cached instance. Use .refresh() to update all.
Scenario
Load Functions
Remote Functions
Page-level data needed before render
✅ Preferred
⚠️ Works but less optimal
SEO-critical data in initial HTML
✅ Preferred
✅ Works with SSR
Component-level data fetching
⚠️ Awkward
✅ Preferred
Interactive features (likes, comments)
⚠️ Requires +server.ts
✅ Preferred
Form submissions
✅ Form actions
✅ form() function
Native TypeScript without $types
❌ Requires generated types
✅ Native
Remote Functions Only Pattern
It's possible to build a SvelteKit app using only remote functions without any load functions. This simplifies the mental model by treating all data fetching as component-level concerns.
Project Structure (No Load Functions):
src/
├── routes/
│ ├── +layout.svelte # Root layout (no +layout.server.ts)
│ ├── +page.svelte # Home page
│ ├── todos/
│ │ ├── +page.svelte # Todos page (no +page.server.ts)
│ │ └── data.remote.ts # All data operations
│ └── blog/
│ ├── +page.svelte
│ └── data.remote.ts
└── lib/server/db.ts # Database connection
Example: Todo App with Remote Functions Only
// src/routes/todos/data.remote.tsimport{query,form,command}from'$app/server'import*asdbfrom'$lib/server/database'import*asvfrom'valibot'exportconstgetTodos=query(async()=>{returnawaitdb.sql`SELECT * FROM todos ORDER BY created_at DESC`})exportconstcreateTodo=form(v.object({title: v.pipe(v.string(),v.nonEmpty())}),async({ title })=>{awaitdb.sql`INSERT INTO todos (title, completed) VALUES (${title}, false)`})exportconsttoggleTodo=command(v.object({id: v.number(),completed: v.boolean()}),async({ id, completed })=>{awaitdb.sql`UPDATE todos SET completed = ${completed} WHERE id = ${id}`})
Pages where all data must be present before render
Hybrid Approach
Mix both patterns - use load functions for SEO-critical pages and remote functions for interactive features:
src/routes/
├── +page.svelte # Home - uses load function for SEO
├── +page.server.ts # ← Load function for SEO
├── dashboard/
│ ├── +page.svelte # Dashboard - remote functions only
│ └── data.remote.ts # ← No load function needed
└── blog/
├── +page.server.ts # ← Load function for SEO
└── +page.svelte
Validation required: Always validate arguments with Standard Schema (Valibot, Zod)
Public endpoints: Remote functions create publicly accessible HTTP endpoints
Server hooks still run: hooks.server.ts → handle() executes for remote function calls
7. SSR Gotchas
State Leak Prevention
The Problem: Module-level state persists across requests on the server, potentially leaking data between users.
// stores/counter.svelte.tsfunctioncreateCounter(){letcount=$state(0)return{getcount(){returncount},increment: ()=>count++}}exportconstcounter=createCounter()// ❌ DANGER: Runs ONCE when module is imported
Request A (User Alice):
• Imports counter → gets cached instance
• counter.increment() → count = 1
Request B (User Bob):
• Imports counter → gets SAME cached instance
• counter.count is ALREADY 1 (Alice's data!) ← STATE LEAK!
Why Component State Doesn't Leak: State declared inside .svelte components is safe because SvelteKit creates fresh component instances for each request.
Context-Based Stores
Use Svelte's setContext/getContext for SSR-safe state:
// stores/my-store.svelte.tsimport{getContext,setContext}from'svelte'constMY_STORE_KEY=Symbol('myStore')functioncreateMyStore(){letvalue=$state('')return{getvalue(){returnvalue},setvalue(v: string){value=v}}}// Call ONCE in a layout to create and set the storeexportfunctionsetMyStore(){conststore=createMyStore()setContext(MY_STORE_KEY,store)returnstore}// Call in any descendant component to access the storeexportfunctiongetMyStore(){returngetContext<ReturnType<typeofcreateMyStore>>(MY_STORE_KEY)}
Usage:
<!-- +layout.svelte - Initialize ONCE at the top -->
<scriptlang='ts'>
import { setMyStore } from'$lib/stores/my-store.svelte'const myStore =setMyStore()
</script>
<slot />
<!-- Any descendant component -->
<scriptlang='ts'>
import { getMyStore } from'$lib/stores/my-store.svelte'const myStore =getMyStore()const value =$derived(myStore.value) // Use $derived for reactivity
</script>
// +page.server.ts - Private data (DB, secrets)exportasyncfunctionload({ locals }){constuser=awaitdb.getUser(locals.userId)return{ user }}// +page.ts - Public API data (runs in browser too)exportasyncfunctionload({ data, fetch }){constposts=awaitfetch('https://api.example.com/posts')return{ ...data,posts: awaitposts.json()}}
Example Route Structure
For a /dashboard route in a typical app:
src/
├── hooks.server.ts # Server hook (auth, logging) - SERVER ONLY
├── hooks.client.ts # Client hook (error handling) - CLIENT ONLY (optional)
├── hooks.ts # Universal hook (reroute) - BOTH
│
└── routes/
├── +layout.svelte # Root layout (nav, footer) - BOTH
├── +layout.ts # Root universal load - BOTH
├── +layout.server.ts # Root server load (session) - SERVER ONLY
│
└── (auth)/ # Route group (no URL segment)
├── +layout.svelte # Auth layout (sidebar) - BOTH
├── +layout.server.ts# Auth check, user data - SERVER ONLY
│
└── dashboard/
├── +page.svelte # Dashboard UI - BOTH
├── +page.ts # Dashboard universal load - BOTH
└── +page.server.ts # Dashboard server load - SERVER ONLY
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}
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!