Server-Side Rendering Gotchas with Runes in Svelte 5
The Universal Application Paradigm
Server-side rendering represents a paradigm shift in how we think about web application architecture. Unlike the early days of single-page applications where JavaScript was solely responsible for rendering content in the browser, modern frameworks like SvelteKit embrace a “universal” approach where your application code executes in multiple environments. This architectural decision brings tremendous benefits — faster initial page loads, improved SEO, better accessibility, and graceful degradation when JavaScript fails — but it also introduces a category of challenges that many developers find confusing and frustrating.
When Svelte 5 introduced runes as the new reactive primitive, it fundamentally changed how developers express reactivity in their applications. The $state, $derived, and $effect runes provide a more explicit and predictable reactivity model compared to Svelte 4’s implicit reactive declarations. However, this new model interacts with server-side rendering in ways that require careful understanding. A rune that works perfectly in a client-only context might cause subtle bugs, hydration mismatches, or outright crashes when your component runs on the server.
This tutorial aims to build a comprehensive mental model for understanding SSR with Svelte 5 runes. Rather than simply providing code snippets to copy, we’ll explore the underlying principles that govern how code executes across environments, why certain patterns cause problems, and how to think about component design in a way that naturally avoids SSR pitfalls. By the end of this deep dive, you’ll possess not just solutions to common problems, but the conceptual framework to reason about new situations you encounter.
Understanding the Fundamental Nature of Universal Applications
To truly grasp SSR gotchas, we must first understand what it means for an application to be “universal.” This term describes applications where the same codebase runs in fundamentally different environments: the server (typically Node.js, but potentially Deno, Bun, or edge runtimes like Cloudflare Workers) and the browser. These environments share JavaScript as a language but differ dramatically in their available APIs, execution models, and purposes.
Consider what happens when a user navigates to your SvelteKit application for the first time. Their browser sends an HTTP request to your server. The server receives this request and needs to respond with HTML that the browser can display. In a traditional server-rendered application (think PHP or Ruby on Rails), the server would use templates to generate this HTML directly. In a client-side SPA, the server would return a minimal HTML shell, and JavaScript would construct the entire page in the browser.
SvelteKit takes a hybrid approach. When the server receives the request, it actually runs your Svelte components — executing the JavaScript code you wrote — to produce the initial HTML. This means your component’s <script> block executes on the server, your reactive declarations are evaluated, and Svelte renders the resulting markup into an HTML string. This HTML is sent to the browser, where the user immediately sees meaningful content.
But the story doesn’t end there. The browser also receives your JavaScript bundle. Once this JavaScript loads and executes, Svelte performs a process called “hydration” where it takes the existing server-rendered HTML and makes it interactive. During hydration, your component code runs again — this time in the browser — and Svelte attaches event handlers, sets up reactive subscriptions, and prepares the application for user interaction.
This dual execution is the source of every SSR gotcha we’ll discuss. Your code runs twice: once on the server to generate HTML, and once on the client to enable interactivity. Understanding this fundamental reality is the key to avoiding problems.
Why Does This Architecture Exist?
Before diving into the technical details, it’s worth understanding why SvelteKit (and frameworks like Next.js, Nuxt, and Remix) chose this architecture. The benefits are substantial enough to justify the additional complexity.
Performance and perceived speed represent the primary motivation. When a user requests a page, they want to see content as quickly as possible. With client-side rendering, the browser must download the HTML shell, then download the JavaScript bundle, then execute the JavaScript to render content. Only after all these steps complete does the user see anything meaningful. With SSR, the server sends complete HTML immediately. The user sees content while JavaScript downloads in the background. This dramatically improves perceived performance, especially on slower connections or less powerful devices.
Search engine optimization provides another compelling reason. While modern search engines can execute JavaScript and index client-rendered content, SSR ensures that crawlers see complete content immediately. This eliminates uncertainty about how search engines will interpret your pages and can improve your search rankings.
Accessibility and progressive enhancement benefit significantly from SSR. Users who browse with JavaScript disabled (whether by choice or due to corporate firewalls) can still access your content. Screen readers receive complete markup immediately rather than waiting for JavaScript execution. The application degrades gracefully when things go wrong.
Social media sharing relies on SSR for generating link previews. When someone shares a link to your site on Twitter or Facebook, those platforms fetch the HTML and extract metadata. They don’t execute JavaScript, so client-rendered content wouldn’t appear in previews.
These benefits explain why the industry has largely moved toward SSR for production applications. Understanding this context helps frame the challenges we’ll address: SSR complexity is the price we pay for these substantial benefits.
The Two Environments: A Detailed Comparison
Let’s examine the differences between server and browser environments in detail, as these differences are the root cause of most SSR issues.
The server environment runs in Node.js (or a similar runtime). This environment has no concept of a visual display, user interaction, or browser APIs. There’s no window object, no document, no localStorage, no navigator, no IntersectionObserver, no ResizeObserver. The server doesn’t know the user’s screen size, color scheme preference, timezone, or locale. It cannot play audio, render to canvas, or access the clipboard. The server’s job is to process a request and produce a response as quickly as possible — ideally in milliseconds.
Server execution is also fundamentally different in its lifecycle. When the server renders your component, it runs your code synchronously, produces HTML, and discards all state. There’s no ongoing process watching for changes. The server renders once and moves on. If 1000 users request the same page, the server renders it 1000 times (unless you implement caching), with each render being independent and stateless.
The browser environment is rich with APIs but constrained in different ways. It has access to all the web platform APIs — DOM manipulation, storage, device sensors, media playback — but it runs in a sandbox with security restrictions. The browser cannot access the file system, execute shell commands, or connect to databases directly. It runs in an event loop that responds to user interactions, network responses, and timer callbacks.
Browser execution is also persistent within a session. Once your component mounts, it stays alive until the user navigates away or closes the tab. State persists across interactions. Effects run and re-run in response to state changes. The component exists in time, responding to an ongoing stream of events.
The crucial insight is that server and browser environments share almost nothing except the JavaScript language itself. When you write code that assumes browser APIs exist, that code will crash on the server. When you write code that assumes persistent state, that code won’t work correctly with SSR’s render-once model. Successful SSR requires writing code that works correctly in both environments — or explicitly handling the differences.
The SSR Timeline: A Moment-by-Moment Analysis
Understanding exactly when code executes is essential for avoiding SSR problems. Let’s trace through the complete lifecycle of a page request, examining what happens at each stage.
Phase 1: The Server Receives a Request
Everything begins with an HTTP request. A user types a URL, clicks a link, or submits a form. Their browser sends a request to your server. SvelteKit’s server-side code receives this request and begins processing it.
During this phase, SvelteKit runs your +page.server.js and +page.js load functions (if they exist). These functions can access server-side resources like databases, make authenticated API calls, and prepare data for your components. The data returned from load functions becomes available as props to your page components.
This is also when cookies are read, user sessions are validated, and any server-side logic executes. Crucially, this happens before any component code runs. By the time your Svelte components execute, the load functions have already completed.
Phase 2: Component Execution on the Server
With data loaded, SvelteKit begins rendering your component tree. Starting from the root layout and working down through nested layouts to your page component, Svelte executes each component’s <script> block.
This is where things get interesting for runes. When the server executes your component:
$state declarations run and initialize. The initial value you provide becomes the state’s value. If you write let count = $state(0), the variable count holds the value 0. Any subsequent synchronous modifications to this state are captured — if you immediately write count = 10, the state becomes 10.
$derived declarations compute their values. Svelte evaluates the derivation expression using the current state values. If you write let doubled = $derived(count * 2), and count is 10, then doubled becomes 20.
$effect blocks are completely ignored. This is perhaps the most important rule to internalize. Effects don’t run on the server. Period. The server skips over $effect blocks entirely. This makes sense when you think about it: effects are designed for side effects that respond to state changes over time, but the server renders once and discards the result. There’s no “over time” on the server.
$props receive values from parent components. Props work identically on server and client — they’re just values passed from parent to child.
onMount callbacks are not called. Like effects, onMount only runs in the browser. The callback is registered but not executed.
onDestroy callbacks are called after rendering completes. This is the one lifecycle hook that runs on the server, allowing cleanup of any resources allocated during the render pass.
After all components have executed, Svelte serializes the resulting component tree into an HTML string. This HTML represents the complete initial state of your page.
Phase 3: HTML Delivery to the Browser
The server sends the HTML response to the browser. This response includes the rendered HTML, plus <script> tags that will load your JavaScript bundle, plus any serialized data that the client needs for hydration.
At this moment, the user sees your page. Content is visible and readable. Links are present (though clicking them would cause full page reloads without JavaScript). Forms exist (and would work via traditional form submission without JavaScript). The page is usable, albeit not fully interactive.
This is the SSR payoff: meaningful content appears before JavaScript even starts loading. On slow connections, this could save seconds of waiting. On fast connections, it’s still perceptibly faster than waiting for JavaScript to render everything.
Phase 4: JavaScript Loading and Parsing
While the user views the HTML, their browser downloads your JavaScript bundle in the background. Depending on bundle size and connection speed, this might take anywhere from milliseconds to several seconds.
Once downloaded, the browser parses and executes the JavaScript. This is computationally expensive, especially on mobile devices. The main thread is busy during this time, which can cause the page to feel unresponsive if the bundle is large.
Phase 5: Hydration
Hydration is where the magic happens — and where SSR bugs often manifest. During hydration, Svelte “attaches” to the existing DOM rather than creating new elements.
The hydration process works roughly like this:
- Svelte creates its internal component tree structure, mirroring what was created on the server.
- For each component, Svelte runs the
<script>block again — yes, your code runs a second time. - Svelte walks through the existing DOM, matching elements to its internal representation.
- Event handlers are attached to existing DOM elements.
- Reactive subscriptions are established so the UI can respond to state changes.
$effectblocks run for the first time.onMountcallbacks are called.
The critical point is step 3: Svelte expects the existing DOM to match what it would have rendered. If the server rendered <p>Hello</p> but the client code would render <p>World</p>, Svelte detects this mismatch. In development, you’ll see console warnings. In production, Svelte attempts to reconcile the difference, but this can cause visual glitches, broken event handlers, or incorrect behavior.
This is why hydration mismatches occur: when server and client produce different output. Understanding the causes of these mismatches — and how to avoid them — is essential for SSR success.
Phase 6: Client-Side Navigation
After hydration completes, your application is fully interactive. The user can click buttons, fill forms, and trigger state changes. Svelte’s reactivity system responds to these changes, updating the DOM as needed.
When the user navigates to a different page within your application (by clicking an internal link), SvelteKit intercepts the navigation and handles it client-side. No new server request is made for the HTML. Instead, SvelteKit loads the new page’s JavaScript module (if not already cached), fetches data from load functions, and mounts the new page component.
This client-side navigation doesn’t involve SSR. Components mount fresh in the browser. This is why some SSR bugs only appear on initial page load but not on subsequent navigation — client-side navigation skips the server entirely.
Deep Dive: How Each Rune Behaves During SSR
Now that we understand the timeline, let’s examine each rune’s behavior in detail. Understanding these behaviors will help you anticipate problems before they occur.
The $state Rune: Reactive State Initialization
The $state rune creates reactive state that triggers UI updates when modified. On the surface, it seems straightforward: you declare a variable with an initial value, and that variable becomes reactive.
<script>
let count = $state(0)
let user = $state({ name: 'Anonymous', loggedIn: false })
let items = $state(['apple', 'banana', 'cherry'])
</script> During SSR, these declarations work exactly as you’d expect. The variables initialize with their provided values. If you modify them synchronously during the script’s execution, those modifications take effect:
<script>
let count = $state(0)
// This synchronous modification happens during SSR
count = 10
// The HTML will show 10, not 0
</script>
<p>Count: {count}</p> The complexity arises when you try to initialize state from sources that don’t exist on the server. Consider this seemingly reasonable code:
<script>
// DANGEROUS: localStorage doesn't exist on the server!
let savedTheme = $state(localStorage.getItem('theme') || 'light')
</script> This code will crash during SSR with a “localStorage is not defined” error. The server has no localStorage — it’s a browser-only API. The same applies to any initialization that depends on browser APIs:
<script>
// DANGEROUS: window doesn't exist on the server!
let windowWidth = $state(window.innerWidth)
// DANGEROUS: document doesn't exist on the server!
let cookies = $state(document.cookie)
// DANGEROUS: navigator doesn't exist on the server!
let userAgent = $state(navigator.userAgent)
</script> The solution involves providing safe default values and deferring browser-specific initialization to $effect, which we’ll explore shortly.
Another subtle issue involves deep reactivity. When you use $state with an object or array, Svelte creates a deeply reactive proxy. This works identically on server and client, but the proxy behavior has implications for serialization:
<script>
let todos = $state([
{ id: 1, text: 'Learn SSR', done: false },
{ id: 2, text: 'Avoid gotchas', done: false }
])
// This is a deeply reactive proxy
// Modifications to nested properties trigger updates
todos[0].done = true // This works reactively
</script> The proxy nature means you can’t directly serialize $state values with JSON.stringify — you need to use $state.snapshot() first. This typically isn’t an issue during normal SSR, but it matters if you’re serializing state for debugging or data transfer.
The $derived Rune: Computed Values and Their Dependencies
Derived state represents computed values that automatically update when their dependencies change. Conceptually, derivations are pure functions of other state:
<script>
let firstName = $state('John')
let lastName = $state('Doe')
// This derivation depends on firstName and lastName
let fullName = $derived(`${firstName} ${lastName}`)
// Complex derivations use $derived.by
let initials = $derived.by(() => {
return `${firstName[0]}${lastName[0]}`
})
</script> Derivations work beautifully during SSR because they’re pure computations without side effects. The server evaluates the derivation expression using current state values and renders the result. The client re-evaluates during hydration and (assuming the same inputs) produces the same output.
The gotcha with derivations occurs when they depend on state that differs between server and client. Consider this example:
<script>
import { browser } from '$app/environment'
let screenWidth = $state(browser ? window.innerWidth : 1024)
let isMobile = $derived(screenWidth < 768)
</script>
<nav class:mobile={isMobile}>
<!-- Navigation content -->
</nav> During SSR, screenWidth is 1024 (our fallback), so isMobile is false. The server renders <nav> without the mobile class. But if the user has a 600px wide phone screen, hydration sets screenWidth to 600, making isMobile become true. The client expects <nav class="mobile">, but the DOM has <nav>. Hydration mismatch!
The solution is to ensure derivations produce consistent values between server and client during initial render. We’ll explore patterns for this in later sections.
Another consideration is that derivations cannot contain side effects. Svelte enforces this — you cannot modify state inside a $derived expression:
<script>
let count = $state(0)
let log = $state([])
// ERROR: Cannot modify state inside $derived
let doubled = $derived(() => {
log.push(count) // This is forbidden!
return count * 2
})
</script> This restriction exists because derivations might be evaluated multiple times (for dependency tracking) or might not be evaluated at all (if nothing reads them). Side effects inside derivations would have unpredictable behavior. This restriction applies equally during SSR and in the browser.
The $effect Rune: Your Safe Harbor for Browser Code
The $effect rune creates side effects that run in response to state changes. Unlike $state and $derived, effects are fundamentally client-only. They never run on the server.
This behavior is intentional and crucial to understand. Effects are designed for tasks like:
- Interacting with browser APIs (localStorage, DOM measurements, etc.)
- Subscribing to external data sources
- Performing logging or analytics
- Integrating with third-party libraries that require browser context
- Managing timers and intervals
- Handling focus and keyboard interactions
None of these make sense in a server context. The server has no DOM to measure, no localStorage to read, no browser to interact with. By skipping effects during SSR, Svelte keeps server rendering fast and avoids crashes from missing browser APIs.
<script>
let mousePosition = $state({ x: 0, y: 0 })
$effect(() => {
// This entire block is ignored during SSR
// It only runs after hydration in the browser
function handleMouseMove(event) {
mousePosition = { x: event.clientX, y: event.clientY }
}
window.addEventListener('mousemove', handleMouseMove)
// Cleanup function runs when effect re-runs or component unmounts
return () => {
window.removeEventListener('mousemove', handleMouseMove)
}
})
</script>
<p>Mouse: ({mousePosition.x}, {mousePosition.y})</p> The pattern shown above is foundational for SSR-safe browser interactions. Notice that mousePosition has an SSR-safe default value of { x: 0, y: 0 }. The server renders this default. After hydration, the effect runs and starts updating mousePosition with actual mouse coordinates.
Effects also handle reactive dependencies automatically. Svelte tracks which state values you read inside the effect and re-runs the effect when those values change:
<script>
let userId = $state(null)
let userData = $state(null)
$effect(() => {
// This effect re-runs whenever userId changes
if (!userId) {
userData = null
return
}
// Fetch user data from API
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
userData = data
})
})
</script> Understanding effect dependencies is important because it determines when effects re-run. Values read synchronously within the effect body (before any await or inside setTimeout) become dependencies. Values read asynchronously (after await or inside callbacks) don’t become dependencies:
<script>
let searchTerm = $state('')
let results = $state([])
let sortOrder = $state('asc')
$effect(() => {
// searchTerm is a dependency (read synchronously)
const term = searchTerm
if (!term) {
results = []
return
}
// Perform search
fetch(`/api/search?q=${encodeURIComponent(term)}`)
.then((res) => res.json())
.then((data) => {
// sortOrder is NOT a dependency (read asynchronously)
// Changing sortOrder won't trigger a new search
results = data.sort((a, b) => (sortOrder === 'asc' ? a.rank - b.rank : b.rank - a.rank))
})
})
</script> This dependency tracking works the same way after hydration as it would in a client-only component. The only difference is that the initial effect run happens after hydration rather than after component initialization.
The $effect.pre Rune: Pre-DOM Update Side Effects
The $effect.pre rune is a specialized variant that runs before DOM updates rather than after. Like regular effects, pre-effects only run on the client — never on the server.
Pre-effects solve a specific problem: sometimes you need to measure or save DOM state before an update, then use that information to perform an action after the update. The classic example is maintaining scroll position in a chat window:
<script>
let messages = $state([])
let containerRef = $state()
let shouldAutoScroll = $state(true)
$effect.pre(() => {
// This runs BEFORE the DOM updates with new messages
if (!containerRef) return
// Check if user is scrolled to the bottom
const { scrollTop, scrollHeight, clientHeight } = containerRef
shouldAutoScroll = scrollHeight - scrollTop <= clientHeight + 50
})
$effect(() => {
// This runs AFTER the DOM updates with new messages
if (shouldAutoScroll && containerRef) {
containerRef.scrollTop = containerRef.scrollHeight
}
})
</script> The pre-effect checks if the user is at the bottom of the scroll container before new messages arrive. If they are, we set a flag. The regular effect then runs after the DOM updates (including the new messages) and scrolls to the bottom if the flag was set.
Without pre-effects, you’d face a race condition: by the time the regular effect runs, the DOM has already updated, and you can’t determine whether the user was at the bottom before the update.
Because pre-effects don’t run on the server, any DOM measurements they perform are safely deferred to the client. The server renders with whatever initial state you provide, and the pre-effect’s logic kicks in only after hydration.
The $props Rune: Component Communication Across Boundaries
Props work identically on server and client — they’re simply values passed from parent to child components. However, props can still cause SSR issues if parents pass different values in different contexts:
<!-- Parent.svelte -->
<script>
import { browser } from '$app/environment'
import Child from './Child.svelte'
// This prop value differs between server and client!
let screenSize = browser ? (window.innerWidth > 768 ? 'desktop' : 'mobile') : 'unknown'
</script>
<Child {screenSize} /> <!-- Child.svelte -->
<script>
let { screenSize } = $props()
</script>
<div class="layout" class:mobile={screenSize === 'mobile'}>
<!-- Content -->
</div> The parent passes screenSize = 'unknown' during SSR and screenSize = 'desktop' or 'mobile' during hydration. The child renders different class names, causing a hydration mismatch.
The solution is the same as with state: use consistent defaults and update in effects:
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte'
// Start with a safe default
let screenSize = $state('desktop') // Reasonable assumption
$effect(() => {
// Update to actual value after hydration
screenSize = window.innerWidth > 768 ? 'desktop' : 'mobile'
// Keep it updated on resize
function handleResize() {
screenSize = window.innerWidth > 768 ? 'desktop' : 'mobile'
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
})
</script>
<Child {screenSize} /> Now both server and client initially render with screenSize = 'desktop', avoiding the mismatch. After hydration, the effect updates to the actual screen size.
Lifecycle Hooks: onMount and onDestroy in SSR Context
While not technically runes, the lifecycle hooks deserve mention because they interact with SSR in specific ways.
onMount only runs on the client, after hydration. It does not run on the server. This makes it another safe harbor for browser-specific code:
<script>
import { onMount } from 'svelte'
let chartRef = $state()
onMount(() => {
// Safe to use browser APIs here
// This only runs after hydration
const chart = new ChartLibrary(chartRef, {
// chart configuration
})
// Return cleanup function
return () => chart.destroy()
})
</script>
<div bind:this={chartRef}></div> The difference between onMount and $effect is subtle but important. onMount runs exactly once after the component mounts. $effect runs after mount and again whenever its dependencies change. For initialization code that should run once, either works. For code that should respond to state changes, use $effect.
onDestroy runs in both environments — on the server after rendering, and on the client when the component unmounts. This is the only lifecycle hook that executes during SSR. Use it to clean up any resources allocated during the synchronous script execution:
<script>
import { onDestroy } from 'svelte'
// If you allocate resources synchronously during SSR...
const subscription = someGlobalEventBus.subscribe(handleEvent)
// ...you can clean them up with onDestroy
onDestroy(() => {
subscription.unsubscribe()
})
</script> In practice, you rarely need onDestroy for SSR cleanup because most resources (DOM elements, browser API subscriptions, etc.) are only created on the client through effects or onMount.
The Hydration Mismatch Problem: Causes, Detection, and Solutions
Hydration mismatches represent the most common and frustrating category of SSR bugs. Let’s explore this topic thoroughly, understanding not just the symptoms but the underlying causes and systematic solutions.
What Exactly Is a Hydration Mismatch?
When Svelte hydrates a page, it expects the existing DOM to match what it would render given the current state. The server rendered HTML based on state at render time. The client reconstructs component state during hydration. If these states differ, the rendered output differs, creating a mismatch.
Svelte detects mismatches by walking the DOM during hydration and comparing each element to its expectations. When it finds a difference — wrong text content, missing attribute, extra element, incorrect class — it logs a warning in development mode.
In production, Svelte tries to recover gracefully. For minor mismatches (wrong text content), it may simply update the DOM to match client expectations. For structural mismatches (different elements or missing children), recovery is more complicated and may result in broken event handlers or visual glitches.
The insidious nature of hydration mismatches is that they often appear to “work” in production. The page renders, the content appears correct, everything seems fine. But subtle bugs lurk: an event handler attached to the wrong element, a component state out of sync with DOM state, or accessibility attributes in an inconsistent state.
Category 1: Time-Based Mismatches
Time represents a fundamental difference between server and client environments. The server renders at one moment in time; the client hydrates at a later moment. Any content that depends on the current time can mismatch.
Consider a component that displays relative times like “5 minutes ago” or “yesterday”:
<script>
let { timestamp } = $props()
// PROBLEM: "now" is different on server vs client
function getRelativeTime(ts) {
const now = Date.now()
const diff = now - ts
if (diff < 60000) return 'just now'
if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`
return 'yesterday'
}
let relativeTime = $derived(getRelativeTime(timestamp))
</script>
<span>{relativeTime}</span> If the server renders at 12:00:00 and the client hydrates at 12:00:05, a timestamp from 11:59:50 shows “just now” on the server but “15 seconds ago” (rounding to “just now”) on the client — or if the timing crosses a threshold, “just now” vs “1 minute ago.” Mismatch!
Solution: Render a stable representation server-side, enhance on client:
<script>
let { timestamp } = $props()
// Start with a stable representation that won't mismatch
// Use the absolute date/time, not relative
let displayTime = $state(new Date(timestamp).toLocaleString())
$effect(() => {
// Update to relative time on the client
function updateRelativeTime() {
const now = Date.now()
const diff = now - timestamp
if (diff < 60000) {
displayTime = 'just now'
} else if (diff < 3600000) {
displayTime = `${Math.floor(diff / 60000)} minutes ago`
} else if (diff < 86400000) {
displayTime = `${Math.floor(diff / 3600000)} hours ago`
} else {
displayTime = new Date(timestamp).toLocaleDateString()
}
}
updateRelativeTime()
// Update periodically for live updates
const interval = setInterval(updateRelativeTime, 60000)
return () => clearInterval(interval)
})
</script>
<time datetime={new Date(timestamp).toISOString()}>
{displayTime}
</time> This approach shows the absolute timestamp during SSR (stable, deterministic), then switches to relative time after hydration (dynamic, user-friendly).
Category 2: Randomness and Non-Deterministic Values
Any value that differs between computations will cause mismatches. The most obvious culprit is Math.random():
<script>
// PROBLEM: Different random values on server vs client
let backgroundColor = $state(`hsl(${Math.random() * 360}, 70%, 80%)`)
</script>
<div style="background-color: {backgroundColor}">Random colored box</div> The server generates one random hue; the client generates a different one. Mismatch!
Solution 1: Pass random values from the server:
The most robust solution is to generate the random value in your server load function and pass it as a prop. This ensures both server render and client hydration use the exact same value.
// +page.server.js
export function load() {
return {
randomHue: Math.random() * 360
}
} <!-- +page.svelte -->
<script>
let { data } = $props()
let backgroundColor = $derived(`hsl(${data.randomHue}, 70%, 80%)`)
</script>
<div style="background-color: {backgroundColor}">Consistently random colored box</div> Solution 2: Two-pass rendering (The “Mount” Pattern):
If you can’t pass data from the server (e.g., inside a reusable component), use a two-pass approach. Render a neutral default state during SSR, then apply the random value only after mounting.
<script>
import { onMount } from 'svelte'
let hue = $state(0) // Default neutral value
let initialized = $state(false)
onMount(() => {
hue = Math.random() * 360
initialized = true
})
</script>
<div
style="background-color: hsl({hue}, 70%, 80%); transition: background-color 0.3s;"
style:opacity={initialized ? 1 : 0.5}
>
Random colored box
</div> This avoids the mismatch because the server and initial client render both use hue = 0. The update happens immediately after mount.
Solution 3: Stable ID generation:
For generating unique IDs (e.g., for accessibility attributes), Svelte 5 doesn’t yet have a built-in useId hook like React, but you can use a global counter or crypto.randomUUID() in a two-pass manner. However, the best practice for accessibility IDs is often to let the consumer pass an ID or use a library that handles stable ID generation.
Category 3: Browser Capability Detection
Different browsers support different features. Code that renders differently based on browser capabilities causes mismatches:
<script>
import { browser } from '$app/environment'
// PROBLEM: Server doesn't know browser capabilities
let supportsWebP = $state(false)
if (browser) {
const canvas = document.createElement('canvas')
supportsWebP = canvas.toDataURL('image/webp').startsWith('data:image/webp')
}
</script>
{#if supportsWebP}
<img src="/image.webp" alt="..." />
{:else}
<img src="/image.jpg" alt="..." />
{/if} The server always renders the JPEG fallback. If the browser supports WebP, it expects the WebP image during hydration. Mismatch!
Solution: Show a consistent initial state, detect capabilities in effect:
<script>
// null = "still detecting"
let supportsWebP = $state(null)
$effect(() => {
const canvas = document.createElement('canvas')
supportsWebP = canvas.toDataURL('image/webp').startsWith('data:image/webp')
})
</script>
<!-- While detecting, show JPEG (consistent with SSR) -->
<!-- After detection, show appropriate format -->
<img src={supportsWebP === true ? '/image.webp' : '/image.jpg'} alt="..." /> Wait — this still causes a mismatch! After hydration, supportsWebP changes from null to true, changing the src attribute.
Better solution: Use the <picture> element with native browser handling:
<picture>
<source srcset="/image.webp" type="image/webp" />
<source srcset="/image.jpg" type="image/jpeg" />
<img src="/image.jpg" alt="..." />
</picture> The <picture> element lets the browser choose the appropriate format without JavaScript. The HTML is identical on server and client, and the browser automatically selects WebP if supported.
Category 4: Locale and Internationalization Differences
The server’s locale settings often differ from the user’s browser, causing formatted values to mismatch:
<script>
let price = $state(1234.56)
// PROBLEM: Server locale might be en-US, user locale might be de-DE
// en-US: "$1,234.56"
// de-DE: "1.234,56 $"
let formattedPrice = $derived(
price.toLocaleString(undefined, {
style: 'currency',
currency: 'USD'
})
)
</script>
<span class="price">{formattedPrice}</span> Using undefined for the locale means “use the environment’s default locale.” The server has one default; the user’s browser has another. Mismatch!
Solution 1: Force a consistent locale:
<script>
let price = $state(1234.56)
// Always use the same locale
let formattedPrice = $derived(
price.toLocaleString('en-US', {
style: 'currency',
currency: 'USD'
})
)
</script> Solution 2: Detect locale client-side and update:
<script>
let price = $state(1234.56)
let userLocale = $state('en-US') // Safe default
$effect(() => {
userLocale = navigator.language
})
let formattedPrice = $derived(
price.toLocaleString(userLocale, {
style: 'currency',
currency: 'USD'
})
)
</script> This causes a flash — the server-rendered format briefly shows before the client format takes over. For prices, this might be acceptable. For critical UI elements, Solution 1 (consistent locale) is often better.
Solution 3: Pass locale from the server:
If you know the user’s locale server-side (from cookies, headers, or user preferences), pass it through load functions:
// +page.server.js
export function load({ request }) {
const acceptLanguage = request.headers.get('accept-language')
const locale = parseAcceptLanguage(acceptLanguage) || 'en-US'
return { locale }
} <!-- +page.svelte -->
<script>
let { data } = $props()
let price = $state(1234.56)
let formattedPrice = $derived(
price.toLocaleString(data.locale, {
style: 'currency',
currency: 'USD'
})
)
</script> Now server and client use the same locale, eliminating the mismatch.
Category 5: Viewport and Device Differences
The server has no viewport. It can’t know the user’s screen size, pixel density, or device orientation. Code that renders based on these factors mismatches:
<script>
import { browser } from '$app/environment'
// PROBLEM: Server can't measure the viewport
let columns = $state(browser ? (window.innerWidth > 1200 ? 4 : 2) : 3)
</script>
<div class="grid" style="--columns: {columns}">
<!-- Grid items -->
</div> The server picks 3 columns as a guess. The client might calculate 4 columns based on the actual viewport. Mismatch!
Solution: Use CSS for responsive layouts, not JavaScript:
<div class="grid">
<!-- Grid items -->
</div>
<style>
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1200px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}
</style> CSS media queries handle responsive layouts without any JavaScript. The HTML is identical on server and client; the browser applies the appropriate styles based on viewport size.
When you absolutely must use JavaScript for layout (perhaps for more complex logic), accept that server and client may differ and handle the transition gracefully:
<script>
import { innerWidth } from 'svelte/reactivity/window'
// innerWidth.current is undefined during SSR
// Provide a reasonable desktop default
let columns = $derived(
(innerWidth.current ?? 1200) > 1200 ? 4 : (innerWidth.current ?? 1200) > 768 ? 3 : 2
)
</script>
<div class="grid" style="--columns: {columns}">
<!-- Grid items -->
</div> Here, we default to assuming a desktop viewport (1200px+) during SSR, which gives 4 columns. On mobile, there’s a brief flash as the grid adjusts after hydration. This is usually acceptable because the layout is close to correct, and the adjustment is minor.
Patterns for Safe Browser API Access
We’ve established that browser APIs don’t exist on the server and must be accessed carefully. Let’s develop a comprehensive set of patterns for common scenarios.
Pattern 1: The Effect Wrapper
The most fundamental pattern wraps all browser API access in $effect:
<script>
let scrollPosition = $state(0)
let viewportHeight = $state(800) // Reasonable default
$effect(() => {
// All browser API access happens here
viewportHeight = window.innerHeight
function handleScroll() {
scrollPosition = window.scrollY
}
function handleResize() {
viewportHeight = window.innerHeight
}
window.addEventListener('scroll', handleScroll)
window.addEventListener('resize', handleResize)
// Initial values
handleScroll()
handleResize()
return () => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
}
})
let scrollProgress = $derived(scrollPosition / (document.body.scrollHeight - viewportHeight))
</script> Wait — there’s a bug above! The $derived references document.body.scrollHeight, which doesn’t exist on the server. Let’s fix it:
<script>
let scrollPosition = $state(0)
let viewportHeight = $state(800)
let documentHeight = $state(2000) // Reasonable default
$effect(() => {
viewportHeight = window.innerHeight
documentHeight = document.body.scrollHeight
function handleScroll() {
scrollPosition = window.scrollY
documentHeight = document.body.scrollHeight
}
function handleResize() {
viewportHeight = window.innerHeight
documentHeight = document.body.scrollHeight
}
window.addEventListener('scroll', handleScroll)
window.addEventListener('resize', handleResize)
handleScroll()
handleResize()
return () => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
}
})
// Now all values in the derivation are safe
let scrollProgress = $derived(
documentHeight <= viewportHeight ? 0 : scrollPosition / (documentHeight - viewportHeight)
)
</script>
<div class="progress-bar" style="width: {scrollProgress * 100}%"></div> Pattern 2: Lazy Browser Module Loading
Some libraries only work in the browser. Rather than importing them normally (which would fail during SSR), load them dynamically:
<script>
let chartContainer = $state()
let chartInstance = $state(null)
$effect(() => {
if (!chartContainer) return
// Dynamic import only happens on the client
import('chart.js').then(({ Chart }) => {
chartInstance = new Chart(chartContainer, {
type: 'bar',
data: chartData,
options: chartOptions
})
})
return () => {
if (chartInstance) {
chartInstance.destroy()
}
}
})
</script>
<canvas bind:this={chartContainer}></canvas> Dynamic import() inside $effect is safe because effects don’t run on the server. The module is only loaded in the browser.
Pattern 3: The Browser Guard with Fallback
Sometimes you want to use a value from a browser API but need a reasonable fallback for SSR:
<script>
import { browser } from '$app/environment'
// Determine color scheme with SSR-safe fallback
let prefersDarkMode = $state(false)
// This runs during SSR (browser is false) and during hydration (browser is true)
// But the value only updates meaningfully after the effect runs
$effect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
prefersDarkMode = mediaQuery.matches
function handleChange(e) {
prefersDarkMode = e.matches
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
})
</script>
<div class:dark-mode={prefersDarkMode}>
<!-- Content -->
</div> Pattern 4: Using svelte:window Bindings
Svelte provides a declarative way to listen to window events and bind to window properties using the <svelte:window> element. This is often cleaner than manually adding event listeners in effects.
<script>
let innerWidth = $state(0)
let innerHeight = $state(0)
let scrollY = $state(0)
let online = $state(true)
// Use safe defaults for derived values
// During SSR, innerWidth will be 0 (or whatever you initialize it to)
let isMobile = $derived(innerWidth > 0 && innerWidth < 768)
</script>
<svelte:window bind:innerWidth bind:innerHeight bind:scrollY bind:online />
<header class:mobile={isMobile} class:scrolled={scrollY > 50}>
{#if !online}
<div class="offline-banner">You are offline</div>
{/if}
<!-- Header content -->
</header> Note that during SSR, the bindings don’t run, so your state variables retain their initial values. This means innerWidth will be 0 (or whatever you set) on the server. Ensure your UI handles this gracefully, perhaps by defaulting to a desktop layout or a loading state to avoid layout shifts if possible, or by accepting that the layout might adjust after hydration.
Pattern 5: Storage APIs (localStorage, sessionStorage)
Storage APIs are browser-only and require careful handling:
<script>
// Initial value used for SSR
let theme = $state('light')
$effect(() => {
// Read from storage on mount
const stored = localStorage.getItem('theme')
if (stored) {
theme = stored
}
})
// Separate effect to write changes
$effect(() => {
// This effect depends on `theme`
// It runs whenever theme changes (after the first effect sets it)
localStorage.setItem('theme', theme)
})
function toggleTheme() {
theme = theme === 'light' ? 'dark' : 'light'
}
</script>
<button onclick={toggleTheme}>
Current theme: {theme}
</button> There’s a subtle issue here: the second effect runs immediately after the first, writing the initial value to storage before we’ve even read from storage. Let’s fix it:
<script>
let theme = $state('light')
let initialized = $state(false)
$effect(() => {
// Read from storage
const stored = localStorage.getItem('theme')
if (stored) {
theme = stored
}
initialized = true
})
$effect(() => {
// Only write after initialization
if (!initialized) return
localStorage.setItem('theme', theme)
})
</script> Or more elegantly, combine reading and writing into a single effect that tracks changes:
<script>
let theme = $state('light')
$effect(() => {
// Read initial value
const stored = localStorage.getItem('theme')
if (stored && stored !== theme) {
theme = stored
}
// Track future changes
return () => {
localStorage.setItem('theme', theme)
}
})
</script> Hmm, this still has issues with the cleanup function capturing stale values. Here’s a cleaner approach using two effects with a flag:
<script>
let theme = $state('light')
let hasLoadedFromStorage = false
$effect(() => {
// First run: load from storage
if (!hasLoadedFromStorage) {
const stored = localStorage.getItem('theme')
if (stored) {
theme = stored
}
hasLoadedFromStorage = true
return
}
// Subsequent runs: save to storage
localStorage.setItem('theme', theme)
})
</script> Actually, the cleanest pattern uses untrack to prevent reading the initial value from triggering a save:
<script>
import { untrack } from 'svelte'
let theme = $state('light')
// Load from storage once
$effect(() => {
const stored = localStorage.getItem('theme')
if (stored) {
theme = stored
}
})
// Save to storage on changes
$effect(() => {
const currentTheme = theme // Read theme (creates dependency)
// Avoid saving on first run
untrack(() => {
// Small delay to ensure we don't save the initial value
const timeout = setTimeout(() => {
localStorage.setItem('theme', currentTheme)
}, 0)
return () => clearTimeout(timeout)
})
})
</script> This is getting complicated! For production code, consider extracting this into a reusable utility:
// lib/persisted.svelte.js
export function createPersistedState(key, initialValue) {
let value = $state(initialValue)
let loaded = false
$effect(() => {
if (!loaded) {
const stored = localStorage.getItem(key)
if (stored !== null) {
try {
value = JSON.parse(stored)
} catch {
value = stored
}
}
loaded = true
} else {
localStorage.setItem(key, JSON.stringify(value))
}
})
return {
get value() {
return value
},
set value(v) {
value = v
}
}
} <script>
import { createPersistedState } from '$lib/persisted.svelte.js'
const theme = createPersistedState('theme', 'light')
</script>
<button onclick={() => (theme.value = theme.value === 'light' ? 'dark' : 'light')}>
Theme: {theme.value}
</button> Pattern 6: Canvas and WebGL
Canvas operations require DOM access and must be fully deferred:
<script>
let canvasRef = $state()
let animationFrame = $state(null)
$effect(() => {
if (!canvasRef) return
const ctx = canvasRef.getContext('2d')
if (!ctx) return
// Animation loop
function draw(timestamp) {
ctx.clearRect(0, 0, canvasRef.width, canvasRef.height)
// Draw your scene
const x = (timestamp / 10) % canvasRef.width
ctx.fillStyle = 'blue'
ctx.fillRect(x, 50, 50, 50)
animationFrame = requestAnimationFrame(draw)
}
animationFrame = requestAnimationFrame(draw)
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
}
})
</script>
<canvas bind:this={canvasRef} width="400" height="200">
<!-- Fallback content for SSR and non-canvas browsers -->
<p>Animated canvas demonstration</p>
</canvas> The <canvas> element renders during SSR with its fallback content. After hydration, the effect takes over and starts drawing.
Pattern 7: Intersection Observer for Lazy Loading
Intersection Observer is commonly used for lazy loading, infinite scroll, and scroll-triggered animations:
<script>
let { src, alt } = $props()
let imgRef = $state()
let isVisible = $state(false)
let hasLoaded = $state(false)
$effect(() => {
if (!imgRef) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
isVisible = true
observer.disconnect() // Only need to load once
}
})
},
{
rootMargin: '100px', // Start loading 100px before visible
threshold: 0
}
)
observer.observe(imgRef)
return () => observer.disconnect()
})
</script>
<div class="image-container" bind:this={imgRef}>
{#if isVisible}
<img {src} {alt} onload={() => (hasLoaded = true)} class:loaded={hasLoaded} />
{:else}
<!-- Placeholder shown during SSR and before intersection -->
<div class="placeholder" aria-hidden="true"></div>
{/if}
</div>
<style>
.image-container {
min-height: 200px; /* Prevent layout shift */
}
img {
opacity: 0;
transition: opacity 0.3s;
}
img.loaded {
opacity: 1;
}
.placeholder {
background: #e0e0e0;
height: 200px;
}
</style> Controlling SSR at the Page Level
SvelteKit provides page options to control SSR behavior for specific pages. Understanding these options helps you make informed decisions about where to apply SSR.
The ssr Option
Setting ssr = false disables server-side rendering for a page:
// +page.js
export const ssr = false With this setting, the server returns a minimal HTML shell with no pre-rendered content. The entire page is rendered by JavaScript in the browser.
When to use ssr = false:
- Pages that fundamentally cannot work without browser APIs (e.g., a WebGL-based game)
- Admin dashboards behind authentication where SEO doesn’t matter
- Pages with complex client-only interactions where SSR adds no value
Tradeoffs:
- No content until JavaScript loads and executes (poor for performance perception)
- No SEO benefit — search engines see an empty page
- Users with JavaScript disabled see nothing
- Losing progressive enhancement and accessibility benefits
Use this option sparingly. Most pages benefit from SSR, and the patterns we’ve discussed allow you to handle browser-only features while keeping SSR enabled.
The csr Option
Setting csr = false disables client-side rendering (hydration):
// +page.js
export const csr = false The page is rendered on the server and sent as static HTML. No JavaScript is sent; no hydration occurs.
When to use csr = false:
- Purely static content pages (about, terms of service, privacy policy)
- Blog posts and articles that don’t need interactivity
- Pages where minimizing JavaScript is critical
Tradeoffs:
- No interactivity — buttons don’t work, forms submit traditionally, no JavaScript at all
- No client-side navigation — every link causes a full page reload
- Can dramatically improve performance for appropriate pages
The prerender Option
Prerendering generates HTML at build time rather than request time:
// +page.js
export const prerender = true During npm run build, SvelteKit crawls your site, renders prerenderable pages, and saves them as static HTML files.
When to use prerender = true:
- Pages with content that doesn’t change per-request (blog posts, documentation)
- Marketing pages that are the same for all users
- Any page that can be cached and served as static files
Tradeoffs:
- Content is fixed at build time — dynamic content requires rebuilds
- Cannot use cookies, headers, or request-specific data during prerender
- Still hydrates on the client unless
csr = falseis also set
You can combine these options for fine-grained control:
// A truly static page: no JavaScript, pre-built at compile time
export const prerender = true
export const csr = false
// A fully dynamic page: no SSR, entirely client-rendered
export const ssr = false
// (csr is true by default) Advanced SSR Patterns and Techniques
Let’s explore some sophisticated patterns for handling complex SSR scenarios.
Conditional Component Loading
Some components simply cannot work during SSR — they rely too heavily on browser APIs, third-party libraries, or specific runtime conditions. You can skip rendering them entirely during SSR:
<script>
import { browser } from '$app/environment'
import HeavyBrowserComponent from './HeavyBrowserComponent.svelte'
</script>
{#if browser}
<HeavyBrowserComponent />
{:else}
<div class="placeholder" aria-label="Loading interactive feature">
<p>Interactive feature loading...</p>
</div>
{/if} The server renders the placeholder. The client replaces it with the actual component after hydration. This isn’t a mismatch because Svelte knows browser changes between server and client.
For even better performance, use dynamic imports to avoid downloading browser-only code on the server:
<script>
import { browser } from '$app/environment'
let HeavyComponent = $state(null)
if (browser) {
import('./HeavyBrowserComponent.svelte').then((module) => {
HeavyComponent = module.default
})
}
</script>
{#if HeavyComponent}
<svelte:component this={HeavyComponent} />
{:else}
<div class="placeholder">Loading...</div>
{/if} Actually, with Svelte 5 we can use the component directly:
<script>
import { browser } from '$app/environment'
let Component = $state(null)
$effect(() => {
import('./HeavyBrowserComponent.svelte').then((module) => {
Component = module.default
})
})
</script>
{#if Component}
<Component />
{:else}
<div class="placeholder">Loading...</div>
{/if} Server-Client State Handoff
Sometimes you need to pass computed values from server to client without recomputation. The standard SvelteKit way to do this is via data props.
// +page.server.js
export async function load({ fetch }) {
const rawData = await fetchLargeDataset()
const processedData = expensiveTransformation(rawData)
return {
processedData
}
} <!-- +page.svelte -->
<script>
let { data } = $props()
</script>
<DataVisualization data={data.processedData} /> The server runs expensiveTransformation, serializes the result into the page data, and the client receives it ready-to-use. This avoids running expensive logic on the client and ensures consistency.
This pattern is particularly valuable for:
- Machine learning model outputs
- Complex calculations or aggregations
- Data that requires server-side authentication to fetch
- Anything expensive that shouldn’t run twice
Handling Authentication State
Authentication often lives in cookies (accessible during SSR) but needs client-side handling for token refresh, logout, etc.:
<!-- +layout.svelte -->
<script>
import { page } from '$app/state'
// User data from server-side load function
let user = $derived(page.data.user)
// Client-side session management
let sessionExpiresAt = $state(null)
let isRefreshing = $state(false)
$effect(() => {
if (!user) return
// Parse session expiry from JWT or stored value
const token = document.cookie
.split('; ')
.find((row) => row.startsWith('session='))
?.split('=')[1]
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
sessionExpiresAt = new Date(payload.exp * 1000)
} catch {
// Invalid token format
}
}
// Set up automatic refresh before expiry
function checkAndRefresh() {
if (!sessionExpiresAt) return
const timeUntilExpiry = sessionExpiresAt.getTime() - Date.now()
if (timeUntilExpiry < 60000 && !isRefreshing) {
isRefreshing = true
fetch('/api/auth/refresh', { method: 'POST' })
.then((res) => res.json())
.then((data) => {
sessionExpiresAt = new Date(data.expiresAt)
})
.finally(() => {
isRefreshing = false
})
}
}
const interval = setInterval(checkAndRefresh, 30000)
checkAndRefresh()
return () => clearInterval(interval)
})
</script>
<slot /> Portal Components
Portals render content outside the component’s DOM hierarchy, typically for modals, tooltips, or dropdowns. They require DOM manipulation:
<!-- Modal.svelte -->
<script>
import { browser } from '$app/environment'
let { open = $bindable(false), children } = $props()
let portalTarget = $state(null)
$effect(() => {
// Create portal container
const container = document.createElement('div')
container.className = 'modal-portal'
container.setAttribute('role', 'presentation')
document.body.appendChild(container)
portalTarget = container
return () => {
container.remove()
}
})
$effect(() => {
if (!browser) return
if (open) {
document.body.style.overflow = 'hidden'
// Focus trap and escape key handling
function handleKeydown(e) {
if (e.key === 'Escape') {
open = false
}
}
document.addEventListener('keydown', handleKeydown)
return () => {
document.body.style.overflow = ''
document.removeEventListener('keydown', handleKeydown)
}
}
})
</script>
{#if open && portalTarget}
<!-- Content rendered into portal via Svelte's own rendering -->
<div class="modal-overlay" onclick={() => (open = false)}>
<div class="modal-content" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
{@render children()}
</div>
</div>
{/if} The modal doesn’t render during SSR (no portalTarget), which is appropriate since modals typically start closed.
Managing Focus
Focus management is entirely client-side but important for accessibility:
<script>
let { isOpen = $bindable(false) } = $props()
let closeButtonRef = $state()
let previousActiveElement = $state(null)
$effect(() => {
if (isOpen) {
// Save current focus
previousActiveElement = document.activeElement
// Focus the modal
closeButtonRef?.focus()
return () => {
// Restore focus when closing
previousActiveElement?.focus()
}
}
})
</script>
{#if isOpen}
<div class="modal">
<button bind:this={closeButtonRef} onclick={() => (isOpen = false)}> Close </button>
<!-- Modal content -->
</div>
{/if} Debugging SSR Issues
When SSR problems occur, you need systematic debugging approaches.
Environment-Aware Logging
Add logging that distinguishes server from client:
<script>
import { browser, dev } from '$app/environment'
function log(message, ...args) {
if (!dev) return // Only log in development
const prefix = browser ? '🌐 [Browser]' : '🖥️ [Server]'
const timestamp = new Date().toISOString().substr(11, 12)
console.log(`${prefix} ${timestamp} ${message}`, ...args)
}
log('Component initializing')
let count = $state(0)
log('State initialized', { count })
$effect(() => {
log('Effect running')
})
</script> Detecting Hydration Mismatches
Svelte logs hydration mismatches to the console in development. To make them more prominent:
// hooks.client.js
const originalWarn = console.warn
console.warn = (...args) => {
if (args[0]?.includes?.('hydrat')) {
console.error('🚨 HYDRATION MISMATCH:', ...args)
// Optionally trigger a debugger breakpoint
// debugger;
}
originalWarn.apply(console, args)
} Common Error Messages Explained
“window is not defined” or “document is not defined”: You’re accessing browser globals during SSR. Move the code into an $effect or guard with if (browser).
“localStorage is not defined”: Same issue — localStorage is browser-only. Use an effect to access it.
“Hydration completed but contains mismatches”: The server-rendered HTML doesn’t match what the client expected. Check for time-dependent values, random values, browser-specific rendering, or locale differences.
“Cannot read property ‘X’ of undefined”: Often happens when accessing DOM elements before they’re available. Ensure your bind:this refs are populated before using them.
Best Practices Summary
Let’s consolidate what we’ve learned into actionable guidelines:
Always provide SSR-safe default values. Every piece of state should have a reasonable default that makes sense on the server. Don’t assume browser APIs exist during initialization.
Use $effect as your browser API sanctuary. Any code that requires browser APIs belongs in an effect. Effects never run on the server, making them inherently safe for browser-specific code.
Embrace svelte/reactivity/window for reactive window values. These utilities handle SSR automatically and provide reactive updates without manual event listener management.
Test with JavaScript disabled. Your server-rendered HTML should be meaningful and functional. If it’s not, you’re missing SSR benefits.
Be deliberate about hydration expectations. Ask yourself: “Will this render identically on server and client?” If not, restructure to ensure consistency or accept the transition.
Prefer CSS for responsive layouts. Media queries don’t cause hydration mismatches. JavaScript-based responsive logic does.
Guard time-sensitive content. Use stable representations during SSR and enhance with dynamic values after hydration.
Keep page options in mind. ssr, csr, and prerender give you fine-grained control. Use them deliberately based on each page’s needs.
Log with environment awareness. When debugging, knowing whether code ran on server or client is invaluable.
Conclusion
Server-side rendering with Svelte 5’s runes system requires a fundamental shift in thinking. You’re no longer writing code that runs in a single environment; you’re writing code that must work correctly across two fundamentally different contexts, at two different times, with two different sets of available APIs.
The key insight is that SSR problems aren’t mysterious or unpredictable once you understand the underlying mechanics. The server renders synchronously, produces HTML, and discards state. The client hydrates, expecting the DOM to match its expectations. Effects only run on the client. Browser APIs only exist on the client. Armed with this understanding, you can anticipate problems before they occur and design components that work correctly from the start.
The patterns we’ve explored — effect-based browser API access, SSR-safe defaults, hydratable for synchronized values, CSS-based responsive design — aren’t arbitrary conventions. They’re logical consequences of SSR’s fundamental nature. When you understand why these patterns work, you can adapt them to novel situations and debug problems effectively.
Server-side rendering isn’t just a performance optimization or SEO technique. It’s a commitment to building resilient applications that work for everyone: fast connections and slow, powerful devices and weak, JavaScript enabled and disabled, search engines and screen readers. The complexity we’ve navigated is the price of that resilience, and it’s a price worth paying.
Your Svelte 5 applications can now confidently straddle the server-client boundary, delivering fast initial loads while enabling rich interactivity. The gotchas are known, the patterns are established, and you have the mental model to handle whatever challenges arise. Build with confidence.