toolchains/javascript/frameworks/svelte5-runes-static/SKILL.md
Svelte 5 runes + SvelteKit adapter-static (SSG/SSR) patterns for hydration-safe state, store bridges, and reactivity that survives prerendering
npx skillsauth add bobmatnyc/claude-mpm-skills svelte5-runes-staticInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build static-first SvelteKit applications with Svelte 5 runes without breaking hydration. Apply these patterns when using adapter-static (prerendering) and combining global stores with component-local runes.
svelte (Svelte 5 runes core patterns)sveltekit (adapters, deployment, SSR/SSG patterns)typescript-core (TypeScript patterns and validation)vitest (unit testing patterns)Building static-first Svelte 5 applications using runes mode with proper state management patterns that survive prerendering and hydration.
Problem: Runes don't hydrate properly after static prerendering
// ❌ BROKEN - State becomes frozen after SSG
export function createStore() {
let state = $state({ count: 0 });
return {
get count() { return state.count; },
increment: () => { state.count++; }
};
}
Why it fails:
adapter-static prerenders components to HTMLSolution: Use traditional writable() stores for global state
// ✅ WORKS - Traditional stores hydrate correctly
import { writable } from 'svelte/store';
export function createStore() {
const count = writable(0);
return {
count,
increment: () => count.update(n => n + 1)
};
}
Problem: Runes mode disables $ auto-subscription syntax
// ❌ BROKEN - Can't use $ inside $derived
let filtered = $derived($events.filter(e => e.type === 'info'));
// ^^^^^^^ Error: $ not available in runes mode
Solution: Subscribe in $effect() → update $state() → use in $derived()
// ✅ WORKS - Manual subscription pattern
import { type Writable } from 'svelte/store';
let events = $state<Event[]>([]);
$effect(() => {
const unsub = eventsStore.subscribe(value => {
events = value;
});
return unsub;
});
let filtered = $derived(events.filter(e => e.type === 'info'));
Problem: Getters don't establish reactive connections
// ❌ BROKEN - Getter pattern breaks reactivity
export function createSocketStore() {
const socket = writable<Socket | null>(null);
return {
get socket() { return socket; }, // ❌ Not reactive
connect: () => { /* ... */ }
};
}
Solution: Export stores directly
// ✅ WORKS - Direct store exports
export function createSocketStore() {
const socket = writable<Socket | null>(null);
const isConnected = derived(socket, $s => $s?.connected ?? false);
return {
socket, // ✅ Direct store reference
isConnected, // ✅ Direct derived reference
connect: () => { /* ... */ }
};
}
Use writable()/derived() for state that needs to survive SSG/SSR:
// stores/globalState.ts
import { writable, derived } from 'svelte/store';
export const user = writable<User | null>(null);
export const theme = writable<'light' | 'dark'>('light');
export const isAuthenticated = derived(user, $u => $u !== null);
Use runes for component-local state and logic:
<script lang="ts">
import { user } from '$lib/stores/globalState';
// Props with runes
let {
initialCount = 0,
onUpdate = () => {}
}: {
initialCount?: number;
onUpdate?: (count: number) => void;
} = $props();
// Bridge: Store → Rune State
let currentUser = $state<User | null>(null);
$effect(() => {
const unsub = user.subscribe(u => {
currentUser = u;
});
return unsub;
});
// Component-local state
let count = $state(initialCount);
let doubled = $derived(count * 2);
// Effects
$effect(() => {
if (count > 10) {
onUpdate(count);
}
});
function increment() {
count++;
}
</script>
<button onclick={increment}>
{currentUser?.name ?? 'Guest'}: {count} (×2 = {doubled})
</button>
<script lang="ts">
import { type Writable } from 'svelte/store';
// 1. Import global stores (traditional)
const { events: eventsStore, filters: filtersStore } = myGlobalStore;
// 2. Bridge to rune state
let events = $state<Event[]>([]);
let activeFilters = $state<string[]>([]);
$effect(() => {
const unsubEvents = eventsStore.subscribe(v => { events = v; });
const unsubFilters = filtersStore.subscribe(v => { activeFilters = v; });
return () => {
unsubEvents();
unsubFilters();
};
});
// 3. Derived computations (pure runes)
let filtered = $derived(
events.filter(e =>
activeFilters.length === 0 ||
activeFilters.includes(e.category)
)
);
let count = $derived(filtered.length);
let hasEvents = $derived(count > 0);
</script>
{#if hasEvents}
<p>Found {count} events</p>
{#each filtered as event}
<EventCard {event} />
{/each}
{:else}
<p>No events match filters</p>
{/if}
// ✅ Safe for prerendering
export const load = async ({ fetch }) => {
const data = await fetch('/api/data').then(r => r.json());
return { data };
};
<script lang="ts">
import { browser } from '$app/environment';
let { data } = $props();
// ✅ Client-only initialization
$effect(() => {
if (browser) {
// WebSocket, localStorage, etc.
initializeClientOnlyFeatures();
}
});
</script>
// ✅ Avoid hydration mismatches
let timestamp = $state<number | null>(null);
$effect(() => {
if (browser) {
timestamp = Date.now(); // Only set on client
}
});
<!-- ✅ Conditional rendering for client-only content -->
{#if browser}
<LiveClock />
{:else}
<p>Loading clock...</p>
{/if}
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
count?: number;
items: Array<{ id: string; name: string }>;
onSelect?: (id: string) => void;
children?: Snippet;
}
let {
title,
count = 0,
items,
onSelect = () => {},
children
}: Props = $props();
let selected = $state<string | null>(null);
let filteredItems = $derived(
items.filter(item =>
selected === null || item.id === selected
)
);
</script>
<h2>{title} ({count})</h2>
{#each filteredItems as item}
<button onclick={() => onSelect(item.id)}>
{item.name}
</button>
{/each}
{@render children?.()}
<script lang="ts">
import type { Writable, Readable } from 'svelte/store';
interface StoreShape {
data: Writable<string[]>;
status: Readable<'loading' | 'ready' | 'error'>;
}
const stores: StoreShape = getMyStores();
let data = $state<string[]>([]);
let status = $state<'loading' | 'ready' | 'error'>('loading');
$effect(() => {
const unsubData = stores.data.subscribe(v => { data = v; });
const unsubStatus = stores.status.subscribe(v => { status = v; });
return () => {
unsubData();
unsubStatus();
};
});
let isEmpty = $derived(data.length === 0);
let isReady = $derived(status === 'ready');
</script>
<script lang="ts">
let {
value = $bindable(''),
disabled = false
}: {
value?: string;
disabled?: boolean;
} = $props();
let focused = $state(false);
let charCount = $derived(value.length);
let isValid = $derived(charCount >= 3 && charCount <= 100);
</script>
<input
bind:value
{disabled}
onfocus={() => { focused = true; }}
onblur={() => { focused = false; }}
class:focused
class:invalid={!isValid}
/>
<p>{charCount}/100</p>
<script lang="ts">
interface FormData {
email: string;
password: string;
}
let formData = $state<FormData>({
email: '',
password: ''
});
let errors = $state<Partial<Record<keyof FormData, string>>>({});
let isValid = $derived(
formData.email.includes('@') &&
formData.password.length >= 8
);
let canSubmit = $derived(
isValid && Object.keys(errors).length === 0
);
function validate(field: keyof FormData) {
if (field === 'email' && !formData.email.includes('@')) {
errors.email = 'Invalid email';
} else if (field === 'password' && formData.password.length < 8) {
errors.password = 'Password too short';
} else {
delete errors[field];
}
}
async function handleSubmit() {
if (!canSubmit) return;
// Submit logic
const result = await submitForm(formData);
if (result.ok) {
// Success
} else {
errors = result.errors;
}
}
</script>
<form onsubmit={handleSubmit}>
<input
type="email"
bind:value={formData.email}
onblur={() => validate('email')}
/>
{#if errors.email}
<span class="error">{errors.email}</span>
{/if}
<input
type="password"
bind:value={formData.password}
onblur={() => validate('password')}
/>
{#if errors.password}
<span class="error">{errors.password}</span>
{/if}
<button type="submit" disabled={!canSubmit}>
Submit
</button>
</form>
<script lang="ts">
import { writable, derived } from 'svelte/store';
const searchQuery = writable('');
// Traditional derived store with debounce
const debouncedQuery = derived(
searchQuery,
($query, set) => {
const timeout = setTimeout(() => set($query), 300);
return () => clearTimeout(timeout);
},
'' // initial value
);
// Bridge to rune state
let query = $state('');
let debouncedValue = $state('');
$effect(() => {
searchQuery.set(query);
});
$effect(() => {
const unsub = debouncedQuery.subscribe(v => {
debouncedValue = v;
});
return unsub;
});
// Use in derived
let results = $derived(
debouncedValue.length >= 3
? performSearch(debouncedValue)
: []
);
</script>
<input
type="search"
bind:value={query}
placeholder="Search..."
/>
{#each results as result}
<SearchResult {result} />
{/each}
When migrating from Svelte 4 to Svelte 5 with adapter-static:
$: with $derived()export let prop with let { prop } = $props()writable()/derived()$store syntax with manual subscription in $effect()npm run buildbrowser checkimport { mount } from 'svelte';
import { tick } from 'svelte';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.svelte';
describe('Counter', () => {
it('increments count', async () => {
const { component } = mount(Counter, {
target: document.body,
props: { initialCount: 0 }
});
const button = document.querySelector('button');
button?.click();
await tick();
expect(button?.textContent).toContain('1');
});
});
import { get } from 'svelte/store';
import { tick } from 'svelte';
import { describe, it, expect } from 'vitest';
import { createMyStore } from './myStore';
describe('Store Bridge', () => {
it('syncs store to rune state', async () => {
const store = createMyStore();
store.data.set(['item1', 'item2']);
await tick();
expect(get(store.data)).toEqual(['item1', 'item2']);
});
});
// ❌ Over-reactive
let items = $state([1, 2, 3, 4, 5]);
let doubled = $derived(items.map(x => x * 2));
let tripled = $derived(items.map(x => x * 3));
let quadrupled = $derived(items.map(x => x * 4));
// ✅ Compute only what's needed
let items = $state([1, 2, 3, 4, 5]);
let transformedItems = $derived(
mode === 'double' ? items.map(x => x * 2) :
mode === 'triple' ? items.map(x => x * 3) :
items.map(x => x * 4)
);
// Traditional derived store for expensive computations
const expensiveComputation = derived(
[source1, source2],
([$s1, $s2]) => {
// Expensive calculation
return complexAlgorithm($s1, $s2);
}
);
// Bridge to rune
let result = $state(null);
$effect(() => {
const unsub = expensiveComputation.subscribe(v => { result = v; });
return unsub;
});
Cause: Runes in module scope with adapter-static
Fix: Use traditional writable() stores for global state
Cause: Trying to use $store syntax in runes mode
Fix: Use bridge pattern with $effect() subscription
Cause: Store factory with getters instead of direct exports
Fix: Export stores directly, not wrapped in getters
Cause: Client-only state rendered during SSR
Fix: Guard with browser check or use {#if browser}
Use Traditional Stores When:
Use Runes When:
Use Bridge Pattern When:
development
Optimize web performance using Core Web Vitals, modern patterns (View Transitions, Speculation Rules), and framework-specific techniques
development
Best practices for documenting APIs and code interfaces, eliminating redundant documentation guidance per agent.
development
Comprehensive API design patterns covering REST, GraphQL, gRPC, versioning, authentication, and modern API best practices
development
Visual verification workflow for UI changes to accelerate code review and catch ...