Svelte 5 runes + SvelteKit
Explicit, signal-based reactivity (runes) plus the SvelteKit data-loading contract: knowing where code runs and how
loaddata reaches a page.
Two things break Svelte code more than anything else: treating $effect as the default for computed values, and confusing server-only load with universal load. This skill keeps you on the right side of both.
When to use
- Authoring or editing
.svelte/.svelte.js/.svelte.tsfiles. - Choosing
$statevs$derivedvs$effect; migrating Svelte 4let/$:/export letto runes. - Writing or reviewing
+page.svelte,+page.ts,+page.server.ts,+layout*,+server.ts,+error.svelte,hooks.server.ts. loaddesign (universal vs server),PageDatatyping,depends/invalidate, streaming promises, form actions withuse:enhance.- Remote functions (
query/form/command/prerenderin*.remote.ts). - Adapter choice and SSR/CSR/prerender flags; hydration-mismatch debugging.
When NOT to use
- Next.js / React Server Components →
../nextjs/SKILL.md. Different reactivity model and data-loading contract. Never cross runes with RSC. - Pure TypeScript typing (generics, mapped/conditional types) with no Svelte dimension →
../typescript/SKILL.md. - Deploy-target specifics (env, edge, project config) →
../vercel/SKILL.md,../netlify/SKILL.md,../cloudflare/SKILL.md. This skill picks the adapter; those skills own the platform. - React, Vue/Nuxt, Astro, SolidJS, Angular are not in this catalog yet — keep any framework comparison brief and inline; do not link them.
Current as of June 2026: Svelte 5.56.x, SvelteKit 2.61.x. Svelte 5 (runes) shipped Oct 2024.
First: detect Svelte 4 vs 5 — before you write a single rune
The #1 way to hand someone broken advice is to mix Svelte 4 reactivity ($:, export let) with runes in the same file. They are silently incompatible: a $: block in a runes component does nothing reactive. Detect the mode first.
- Read
package.json→dependencies/devDependenciessvelte.^5(or5.x) = runes era.^4= legacy. - Read
svelte.config.js.compilerOptions.runes: trueforces runes everywhere; absent = per-file auto-detect (a file is in runes mode iff it uses any rune). - Grep the target file:
$state(/$props(/$derived(→ runes mode.export let/$:/<slot→ legacy.
Rule: never give $:/export let advice and $state/$props advice for the same component. If the codebase is Svelte 4 and the task is new work, migrate the file to runes first (see references/runes.md migration map) rather than appending runes onto legacy syntax.
Runes decision table
Pick the rune by what the value is, not by habit. Reading a rune registers a dependency; writing one schedules an update.
| You need | Rune | Why |
|---|---|---|
| A reactive value you mutate | $state(v) |
The cell tracked for reads/writes; deep objects/arrays are proxied. |
| A value computed from others | $derived(expr) / $derived.by(fn) |
Recomputed lazily from deps read synchronously; no manual wiring. |
| A side effect (DOM, subscription, log) | $effect(fn) |
Runs after DOM update; return a cleanup fn. Escape hatch, not default. |
| A component input | let { x } = $props() |
Destructure props; supports defaults and rest. |
| A two-way-bindable prop | let { x = $bindable() } = $props() |
Opts the prop into parent bind:x. |
$effect is the escape hatch. If an effect's only job is to set state from other state, it's a $derived in disguise — and worse, writing $state inside an effect that also reads it can loop.
<!-- Bad: effect used to compute → extra render, can loop -->
<script>
let count = $state(0);
let doubled = $state(0);
$effect(() => { doubled = count * 2; });
</script>
<!-- Good: derived value, no effect -->
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
Deep mutation is reactive on $state (it returns a proxy), so mutate in place — you don't need to reassign.
<script>
let todos = $state([{ done: false }]);
// Good: proxied, the push is tracked
function add() { todos.push({ done: false }); }
// Also fine: todos[0].done = true;
</script>
Props with defaults and renaming:
<script>
// Good: default + rename a reserved-ish name + rest props
let { title = 'Untitled', class: klass = '', ...rest } = $props();
</script>
Shared / global state across modules
Reactive shared state must live in a .svelte.js or .svelte.ts module (the .svelte part tells the compiler to process runes). A plain .js file cannot use runes.
The trap: you cannot export let count = $state(0) and reassign it elsewhere — importers get the value at import time, not the live cell, so it appears to "stop being reactive." Export a container whose identity is stable.
// counter.svelte.js
// Bad: importers bind the value, not the reactive cell
export let count = $state(0); // reassigning count elsewhere won't propagate
// Good: object identity is stable; .value stays reactive
export const counter = $state({ value: 0 });
// Good: getter/setter object
let _n = $state(0);
export const n = {
get value() { return _n; },
set value(v) { _n = v; },
};
// Good: a class instance (fields are reactive)
export class Counter {
value = $state(0);
increment() { this.value += 1; }
}
Classic writable/readable/derived stores from svelte/store are still valid and still work — prefer them when you need the $store auto-subscription sugar in templates or are integrating Svelte 4 code. For new runes-first code, the module patterns above are simpler. Depth in references/runes.md.
SvelteKit file map — what runs where
| File | Runs | Purpose |
|---|---|---|
+page.svelte |
client + SSR | The page component; reads data: PageData. |
+page.ts / +layout.ts |
server (SSR) and browser (client nav) | Universal load. No secrets — code ships to the client. |
+page.server.ts / +layout.server.ts |
server only | Server load + actions. DB, secrets, filesystem live here. |
+server.ts |
server only | API route: GET/POST/… returning Response. |
+error.svelte |
client + SSR | Rendered when a load/render throws. |
hooks.server.ts |
server only | handle, handleFetch, handleError middleware. |
Data loading
Universal vs server is the core decision. Secrets, DB clients, and $env/static/private belong in +page.server.ts — +page.ts code is shipped to the browser. Type the return with the generated ./$types.
// +page.server.ts — server only; safe to touch DB/secrets
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
export const load: PageServerLoad = async ({ params, depends }) => {
depends('app:post'); // custom dep, target of invalidate('app:post')
const post = await db.post.find(params.id);
// Top-level promise streams: the page renders, comments arrive later.
return { post, comments: db.comment.forPost(params.id) };
};
<!-- +page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>{data.post.title}</h1>
{#await data.comments}
<p>Loading comments…</p>
{:then comments}
<ul>{#each comments as c}<li>{c.body}</li>{/each}</ul>
{/await}
Streaming a top-level promise works only from a server load. Universal load cannot stream the same way — await there or move the slow fetch to the server. Re-run a load with invalidate('app:post') (matches depends) or invalidateAll(). Patterns for parent(), named actions, and validation live in references/sveltekit-data.md.
Mutations: form actions first, remote functions second
Default to form actions. They live in +page.server.ts, are posted to by a real <form method="POST">, and work without JS. use:enhance upgrades them to no-reload submission progressively.
// +page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
login: async ({ request, cookies }) => {
const data = await request.formData();
const email = String(data.get('email') ?? '');
if (!email) return fail(400, { email, missing: true });
cookies.set('session', '…', { path: '/' });
throw redirect(303, '/dashboard');
},
};
<!-- +page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<form method="POST" action="?/login" use:enhance>
<input name="email" type="email" />
{#if form?.missing}<p>Email required</p>{/if}
<button>Log in</button>
</form>
Remote functions are EXPERIMENTAL (available since SvelteKit 2.27, iterated through 2.61; API subject to change). They live in *.remote.ts and require opting in with both flags in svelte.config.js — kit.experimental.remoteFunctions: true and compilerOptions.experimental.async: true (the latter enables top-level await/awaited deriveds the feature relies on; without it remote functions do not actually enable). Four kinds: query (cached server read), form (progressive, parses its own FormData, no schema), command (mutation outside a form — requires JS), prerender (build-time). Use them for JS-driven flows where a plain form action is awkward; otherwise stick to form actions. Full usage in references/sveltekit-data.md.
SSR / CSR / prerender + adapters
Control rendering per route with module-level exports in +page.ts/+page.server.ts:
export const prerender = true; // render at build time → static HTML
export const ssr = false; // skip server render; client-only
export const csr = false; // no client JS; pure SSR/static
Pick the adapter for the deploy target, then hand platform details to the platform skill:
adapter-auto— zero-config on Vercel / Netlify / Cloudflare. Good default.adapter-node— long-running Node server (your own host, Docker).adapter-static— full prerender; the whole site is static (needsprerender = truereachable everywhere).
Env vars, edge runtime, KV/D1 bindings, build settings: that's ../vercel/SKILL.md, ../netlify/SKILL.md, ../cloudflare/SKILL.md, not this skill.
Anti-patterns
| Anti-pattern | Why it's wrong | Do this instead |
|---|---|---|
$effect to compute a value from $state |
Extra render pass; can loop if it reads what it writes | $derived(expr) / $derived.by(fn) |
export let count = $state(0) for shared state |
Importers bind the value, not the live cell → "not reactive" | Export an object / getter / class instance |
Runes in a plain .js file |
Compiler doesn't process runes there | Name it .svelte.js / .svelte.ts |
Mixing $: / export let with runes in one file |
$: is dead in runes mode → silent non-reactivity |
Migrate the whole file to runes (see references) |
DB call / secret in +page.ts |
Universal load ships to the browser; secret leaks |
Move to +page.server.ts (server-only) |
Streaming a top-level promise from universal load |
Only server load streams |
Await in load, or move the fetch server-side |
Mutating a non-$state object expecting reactivity |
Only $state proxies are tracked |
Wrap the value in $state(...) |
| Reaching for remote functions by default | Experimental, JS-required, API may change | Form actions first; remote functions only when needed |
Verify
Run scripts/verify.sh from the SvelteKit project root: svelte-check → tsc --noEmit → Vitest → vite build. Each tool is detected and skipped (not failed) if absent; it exits non-zero only on a real failure. The build step writes the output dir — not read-only.