10 KiB
SvelteKit SSR Best Practices Guide
This guide documents SSR-safe patterns and anti-patterns for InstaRecipe development. All developers should follow these guidelines to prevent SSR errors.
Table of Contents
- Core Principle
- Browser API Detection
- Lifecycle Hooks
- Runes and Reactivity
- Common Gotchas
- Good Examples from Codebase
- Anti-Patterns to Avoid
Core Principle
SvelteKit renders components on both the server and client. Any browser-only APIs must be guarded or used in browser-only contexts.
Browser-Only APIs (Require Guards)
window.*document.*localStorage,sessionStoragenavigator.*EventSource,WebSocketlocation.*fetchin certain contexts (use SvelteKit's built-in fetch in load functions)
Browser API Detection
Pattern: Use browser from $app/environment
import { browser } from '$app/environment';
if (browser) {
// Safe: only runs in browser
const data = localStorage.getItem('key');
}
Why it works: browser is true only when running in the browser, false during SSR.
Example: EventSource Connection
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let eventSource = $state<EventSource | null>(null);
function startSSEConnection() {
if (!browser) return; // ✅ Guard
eventSource = new EventSource('/api/stream');
}
onMount(() => {
if (browser) { // ✅ Explicit guard
startSSEConnection();
}
});
</script>
Lifecycle Hooks
onMount - Browser-Only Lifecycle
Use onMount for:
- Browser API initialization
- Timer setup (
setInterval,setTimeout) - Event listener registration
- Any browser-only side effects
import { onMount } from 'svelte';
onMount(() => {
// ✅ Only runs in browser (built-in SSR guard)
const interval = setInterval(() => {
// Polling logic
}, 1000);
return () => clearInterval(interval); // Cleanup
});
onDestroy - Cleanup
import { onDestroy } from 'svelte';
onDestroy(() => {
// ✅ Safe for cleanup
eventSource?.close();
});
Runes and Reactivity
$state - Reactive State
let count = $state(0); // ✅ SSR-safe
let user = $state<User | null>(null); // ✅ SSR-safe with null default
// ❌ DON'T: Initialize with browser APIs
let stored = $state(localStorage.getItem('key')); // SSR crash!
// ✅ DO: Load in onMount
let stored = $state<string | null>(null);
onMount(() => {
stored = localStorage.getItem('key');
});
$derived - Computed Values
// ✅ GOOD: Pure computation
let doubled = $derived(count * 2);
let fullName = $derived(`${firstName} ${lastName}`);
// ❌ BAD: Side effects or browser APIs
let data = $derived(localStorage.getItem('key')); // SSR crash!
let userAgent = $derived(navigator.userAgent); // SSR crash!
Rule: $derived must be pure (no side effects, no browser APIs).
$effect - Reactive Side Effects
Critical: $effect runs during both SSR and hydration. Always guard browser APIs!
// ❌ BAD: No browser guard
$effect(() => {
setInterval(() => checkHealth(), 1000); // SSR crash!
});
// ✅ GOOD: With browser guard
$effect(() => {
if (!browser) return;
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
});
// ✅ BETTER: Use onMount for initialization instead
onMount(() => {
const interval = setInterval(() => checkHealth(), 1000);
return () => clearInterval(interval);
});
When to use $effect:
- Synchronizing derived state
- DOM manipulation (with browser guard)
- Reactive cleanup
When NOT to use $effect:
- Initialization (use
onMount) - API calls on mount (use
onMount) - Timer setup (use
onMount)
Common Gotchas
1. Static Constants from Browser APIs
// ❌ BAD: Static properties don't exist during SSR
if (eventSource?.readyState === EventSource.OPEN) // SSR crash!
if (ws.readyState === WebSocket.OPEN) // SSR crash!
// ✅ GOOD: Use numeric constants
if (eventSource?.readyState === 1) // EventSource.OPEN = 1
if (ws.readyState === 1) // WebSocket.OPEN = 1
// ✅ GOOD: Guard the check
if (browser && eventSource?.readyState === EventSource.OPEN)
EventSource States:
EventSource.CONNECTING = 0EventSource.OPEN = 1EventSource.CLOSED = 2
WebSocket States:
WebSocket.CONNECTING = 0WebSocket.OPEN = 1WebSocket.CLOSING = 2WebSocket.CLOSED = 3
2. Event Handlers (Already Safe)
Event handlers (onclick, onsubmit, etc.) only run in the browser, so no guard needed:
<button onclick={() => {
// ✅ Safe: event handlers only run in browser
localStorage.setItem('key', 'value');
}}>
Click me
</button>
3. Timers in Components
// ❌ BAD: At module level
const interval = setInterval(() => {}, 1000); // SSR crash!
// ✅ GOOD: In onMount
onMount(() => {
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
});
4. Conditional Rendering Based on browser
<!-- ⚠️ AVOID: Causes hydration mismatch -->
{#if browser}
<ClientOnlyComponent />
{/if}
<!-- ✅ BETTER: Initialize in onMount with flag -->
<script>
let mounted = $state(false);
onMount(() => {
mounted = true;
});
</script>
{#if mounted}
<ClientOnlyComponent />
{/if}
Why: The server renders one thing, the client renders another, causing hydration warnings.
Good Examples from Codebase
Example 1: PushNotificationManager (Excellent)
src/lib/client/PushNotificationManager.ts
import { browser } from '$app/environment';
export class PushNotificationManager {
private static instance: PushNotificationManager | null = null;
static getInstance() {
if (!browser) return null; // ✅ Early return for SSR
// ... rest of implementation
}
private loadStoredSubscription() {
if (!browser) return null; // ✅ Guard localStorage
const stored = localStorage.getItem('pushSubscription');
return stored ? JSON.parse(stored) : null;
}
}
Why it's good:
- Guards all browser API access
- Early returns prevent unnecessary code execution during SSR
- Defensive programming with null checks
Example 2: Queue Dashboard (Fixed)
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
let eventSource = $state<EventSource | null>(null);
onMount(async () => {
await loadQueueItems();
if (browser) { // ✅ Guard
startSSEConnection();
}
});
function startSSEConnection() {
if (!browser) return; // ✅ Double guard for safety
eventSource = new EventSource('/api/queue/stream');
// ...
}
</script>
<!-- Template: Use numeric constants -->
<div class="indicator {eventSource?.readyState === 1 ? 'online' : 'offline'}"></div>
Example 3: LLM Health Indicator (Fixed)
src/routes/share/components/LlmHealthIndicator.svelte
<script lang="ts">
import { onMount } from 'svelte';
onMount(() => {
// ✅ onMount only runs in browser
checkHealth(); // Initial check
const interval = setInterval(checkHealth, pollInterval);
return () => clearInterval(interval);
});
</script>
Why it's good:
- Uses
onMountinstead of$effectfor initialization - Timer setup in browser-only context
- Proper cleanup with return function
Anti-Patterns to Avoid
❌ Anti-Pattern 1: Browser APIs in $derived
// ❌ DON'T
let theme = $derived(localStorage.getItem('theme'));
// ✅ DO
let theme = $state<string | null>(null);
onMount(() => {
theme = localStorage.getItem('theme');
});
❌ Anti-Pattern 2: Side Effects in $effect Without Guards
// ❌ DON'T
$effect(() => {
// Runs during SSR!
fetch('/api/data');
});
// ✅ DO: Guard browser-specific side effects
$effect(() => {
if (!browser) return;
fetch('/api/data');
});
// ✅ BETTER: Use onMount for initialization
onMount(() => {
fetch('/api/data');
});
❌ Anti-Pattern 3: Static Browser Constants
// ❌ DON'T
if (ws.readyState === WebSocket.OPEN)
// ✅ DO
if (ws.readyState === 1) // WebSocket.OPEN = 1
❌ Anti-Pattern 4: Timers at Module Level
// ❌ DON'T
const interval = setInterval(() => {}, 1000);
// ✅ DO
onMount(() => {
const interval = setInterval(() => {}, 1000);
return () => clearInterval(interval);
});
Testing for SSR Safety
1. Build and Preview
npm run build
npm run preview
Watch server console for errors during build and preview.
2. Check for Hydration Warnings
Open browser DevTools console and look for:
- "Hydration failed"
- "The server response doesn't match the client content"
3. Search for Unguarded APIs
# Search for potential SSR issues
grep -r "window\." src/routes --include="*.svelte"
grep -r "document\." src/routes --include="*.svelte"
grep -r "localStorage" src/routes --include="*.svelte"
grep -r "navigator\." src/routes --include="*.svelte"
Then verify each usage is either:
- In an event handler (safe)
- In
onMount(safe) - Guarded with
if (browser)(safe)
Quick Reference Checklist
When writing component code, ask:
-
Am I using any browser APIs? (
window,document,localStorage, etc.)- Yes: Add
browserguard or useonMount - No: Proceed normally
- Yes: Add
-
Am I using
$effect?- For synchronization: OK, but guard browser APIs
- For initialization: Use
onMountinstead
-
Am I using static properties from browser APIs?
- Yes: Use numeric constants or add
browserguard - No: You're good
- Yes: Use numeric constants or add
-
Does my code need cleanup?
- Yes: Return cleanup function from
onMountor$effect - No: You're good
- Yes: Return cleanup function from
Further Reading
Document Version: 1.0
Last Updated: December 22, 2025
Related Outcome: FixEventSourceSSR