# 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](#core-principle) - [Browser API Detection](#browser-api-detection) - [Lifecycle Hooks](#lifecycle-hooks) - [Runes and Reactivity](#runes-and-reactivity) - [Common Gotchas](#common-gotchas) - [Good Examples from Codebase](#good-examples-from-codebase) - [Anti-Patterns to Avoid](#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`, `sessionStorage` - `navigator.*` - `EventSource`, `WebSocket` - `location.*` - `fetch` in certain contexts (use SvelteKit's built-in fetch in load functions) --- ## Browser API Detection ### Pattern: Use `browser` from `$app/environment` ```typescript 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 ```svelte ``` --- ## Lifecycle Hooks ### `onMount` - Browser-Only Lifecycle **Use `onMount` for:** - Browser API initialization - Timer setup (`setInterval`, `setTimeout`) - Event listener registration - Any browser-only side effects ```typescript 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 ```typescript import { onDestroy } from 'svelte'; onDestroy(() => { // ✅ Safe for cleanup eventSource?.close(); }); ``` --- ## Runes and Reactivity ### `$state` - Reactive State ```typescript let count = $state(0); // ✅ SSR-safe let user = $state(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(null); onMount(() => { stored = localStorage.getItem('key'); }); ``` ### `$derived` - Computed Values ```typescript // ✅ 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! ```typescript // ❌ 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 ```typescript // ❌ 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 = 0` - `EventSource.OPEN = 1` - `EventSource.CLOSED = 2` **WebSocket States:** - `WebSocket.CONNECTING = 0` - `WebSocket.OPEN = 1` - `WebSocket.CLOSING = 2` - `WebSocket.CLOSED = 3` ### 2. Event Handlers (Already Safe) Event handlers (`onclick`, `onsubmit`, etc.) only run in the browser, so no guard needed: ```svelte ``` ### 3. Timers in Components ```typescript // ❌ 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` ```svelte {#if browser} {/if} {#if mounted} {/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](../src/lib/client/PushNotificationManager.ts) ```typescript 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) [src/routes/+page.svelte](../src/routes/+page.svelte) ```svelte
``` ### Example 3: LLM Health Indicator (Fixed) [src/routes/share/components/LlmHealthIndicator.svelte](../src/routes/share/components/LlmHealthIndicator.svelte) ```svelte ``` **Why it's good:** - Uses `onMount` instead of `$effect` for initialization - Timer setup in browser-only context - Proper cleanup with return function --- ## Anti-Patterns to Avoid ### ❌ Anti-Pattern 1: Browser APIs in `$derived` ```typescript // ❌ DON'T let theme = $derived(localStorage.getItem('theme')); // ✅ DO let theme = $state(null); onMount(() => { theme = localStorage.getItem('theme'); }); ``` ### ❌ Anti-Pattern 2: Side Effects in `$effect` Without Guards ```typescript // ❌ 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 ```typescript // ❌ DON'T if (ws.readyState === WebSocket.OPEN) // ✅ DO if (ws.readyState === 1) // WebSocket.OPEN = 1 ``` ### ❌ Anti-Pattern 4: Timers at Module Level ```typescript // ❌ DON'T const interval = setInterval(() => {}, 1000); // ✅ DO onMount(() => { const interval = setInterval(() => {}, 1000); return () => clearInterval(interval); }); ``` --- ## Testing for SSR Safety ### 1. Build and Preview ```bash 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 ```bash # 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 `browser` guard or use `onMount` - **No:** Proceed normally - [ ] Am I using `$effect`? - **For synchronization:** OK, but guard browser APIs - **For initialization:** Use `onMount` instead - [ ] Am I using static properties from browser APIs? - **Yes:** Use numeric constants or add `browser` guard - **No:** You're good - [ ] Does my code need cleanup? - **Yes:** Return cleanup function from `onMount` or `$effect` - **No:** You're good --- ## Further Reading - [SvelteKit Documentation](https://kit.svelte.dev/docs) - [Svelte Runes Documentation](https://svelte.dev/docs/svelte/$state) - [MDN: EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) - [MDN: Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) --- **Document Version:** 1.0 **Last Updated:** December 22, 2025 **Related Outcome:** [FixEventSourceSSR](./outcomes/FixEventSourceSSR.md)