476 lines
10 KiB
Markdown
476 lines
10 KiB
Markdown
# 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
|
|
<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
|
|
|
|
```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<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
|
|
|
|
```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
|
|
<button onclick={() => {
|
|
// ✅ Safe: event handlers only run in browser
|
|
localStorage.setItem('key', 'value');
|
|
}}>
|
|
Click me
|
|
</button>
|
|
```
|
|
|
|
### 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
|
|
<!-- ⚠️ 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](../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
|
|
<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](../src/routes/share/components/LlmHealthIndicator.svelte)
|
|
|
|
```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`
|
|
|
|
```typescript
|
|
// ❌ 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
|
|
|
|
```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)
|