Files
insta-recipe/docs/SVELTEKIT_SSR_GUIDE.md
Giancarmine Salucci 49bccf8f15 simplify
2026-02-18 01:21:44 +01:00

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

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

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 = 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:

<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)

src/routes/+page.svelte

<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 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

// ❌ 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 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


Document Version: 1.0
Last Updated: December 22, 2025
Related Outcome: FixEventSourceSSR