Svelte 5 Runes Conventions
Cursor.cursorrulesCoding
Svelte 5 rules for $state/$derived/$effect discipline, migrating legacy stores, snippets over slots, and typed component props.
Use when: Use in Svelte 5 projects to enforce runes-first reactivity and stop legacy $:, export let, and store patterns from creeping into new code.
Get this skill
Add the skill file with the CLI, by hand, or over MCP.Save this to your project or home directory so Cursor can load it.
npx goodprompts add svelte5-runes-cursorrulesSaves to
./.cursorrulesnpx goodprompts add svelte5-runes-cursorrulesSaves to
./.cursorrulesSkill file
.cursorrules3,895 charactersYou are working in a Svelte 5 + TypeScript project. Runes mode only — legacy syntax (`export let`, `$:` reactive statements, `$$props`, `on:click`) must not appear in new code; when touching a legacy component, migrate it rather than mixing modes.
## $state / $derived discipline
- `$state` is for values that change over time and are owned by this component. Everything computable from other state is `$derived` — never a second `$state` kept in sync manually.
- `$derived(expression)` for one-liners; `$derived.by(() => { ... })` once logic needs statements. If a `$derived.by` exceeds ~15 lines, extract a named pure function and derive from its call.
- `$effect` is a last resort, only for synchronizing with the outside world: DOM measurement, third-party libraries, `localStorage`, analytics. An `$effect` that assigns one piece of state from another is always wrong — use `$derived`.
- Effects that set up subscriptions/timers return a teardown function. Untracked reads inside effects use `untrack()` explicitly, with a comment — never rely on accidental non-tracking.
- Deep reactivity: `$state` on objects/arrays is proxied — mutate directly (`items.push(item)`). Do not clone-and-replace (`items = [...items, item]`) out of React habit; both work, mutation is the house style.
- Cross-component shared state lives in a `.svelte.ts` module exporting `$state`-based objects with mutation functions (`export const session = $state({...})`). Export functions to mutate; never export bare setters of internals.
## Stores migration
- New shared state never uses `writable`/`readable`. Replace store modules with `.svelte.ts` rune modules as you touch them.
- Exception: keep a store when a contract requires the subscribe interface — SvelteKit's `page` on older versions or third-party libraries. Bridge at the edge: read the store with `$` once, put the result into rune state, and keep stores out of component logic.
- `derived()` store chains are tech debt: collapse into `$derived` in the consuming module during migration.
## Component API
- Props via `let { items, onselect, dense = false } = $props()` with an explicit interface: `interface Props { items: Item[]; onselect?: (id: string) => void; dense?: boolean }` and `$props<Props>()` typing (or satisfies pattern the codebase uses). Never `export let`.
- Two-way binding is opt-in and explicit: `let { value = $bindable("") } = $props()`. Limit `$bindable` to form-like leaf components; data flows down, events flow up everywhere else.
- Events are callback props (`onselect`, `onclose`) — `createEventDispatcher` is banned in runes mode. Native event attributes use the new syntax: `onclick={handleClick}`, not `on:click`.
- Children render via snippets: `let { children, header } = $props()` and `{@render children?.()}`. No new `<slot>` usage; named slots become named snippet props.
- Side-effect-free formatting belongs in plain `.ts` utilities, not components. A component over ~150 lines or with 8+ props gets split.
## Props passthrough and attributes
- Forward unknown attributes with `let { class: className, ...rest } = $props()` and spread `{...rest}` onto the root element of wrapper components; merge classes explicitly rather than overwriting.
- Conditional classes use the object/array `class` syntax (`class={["card", { active }]}`), not string concatenation in expressions.
## Misc
- `#each` blocks always key: `{#each items as item (item.id)}`. Never key by index.
- `$inspect(value)` for debugging during development, removed before commit — no `console.log` of `$state` proxies (they log as Proxy noise).
- SSR safety: no `window`/`document` at module top level; gate browser-only code in `$effect` (runs client-side only) or `browser` checks in SvelteKit.
- Derived values that feed templates more than once get a name (`const subtotal = $derived(...)`) instead of repeating the expression inline.