Svelte 5 and SvelteKit rules for AI coding agents
Most Svelte code on the web predates runes, so agents keep writing export let, $: and on:click into Svelte 5 components. That syntax still compiles in legacy mode, but these rules forbid mixing it into runes components. They cover Svelte 5 with SvelteKit 2 and start by checking svelte in package.json.
The runes section shows the replacements: $state for state, $derived for computed values, $props() for props, callback props instead of createEventDispatcher. The SvelteKit sections separate server-only load functions, where database access and secrets belong, from universal loads that also run in the browser, and cover form actions that validate with a schema and return fail(400, { errors, values }) on bad input, plus authentication once in hooks.server.ts.
Folder: rules/sveltekit/ · sveltekit.mdc, CLAUDE.md, AGENTS.md
What the SvelteKit rules tell your agent
- RunesDeclare state with
let count = $state(0), derived values withconst total = $derived(items.reduce(...)), and props withlet { title, onSave } = $props(). - Template syntaxEvent handlers are attributes:
onclick={save}, noton:click. Component events are callback props, notcreateEventDispatcher. - Loading data
+page.server.ts/+layout.server.tsload functions run only on the server; put database access and secrets there.+page.tsloads run on server and client, so they must not import private modules. - MutationsHandle form posts with form actions in
+page.server.ts. Validateawait request.formData()with a schema and returnfail(400, { errors, values })on bad input. - Server and securityAuthenticate once in
hooks.server.tshandleand put the user onevent.locals; type it insrc/app.d.ts.
First rule of each section in sveltekit.mdc, quoted as shipped.
Mistakes these rules are written to prevent
- Accessing
window,documentorlocalStorageat module top level or during SSR. UseonMount,$effect, or checkbrowserfrom$app/environment. - Reading
$pagefrom$app/storesin new code whenpagefrom$app/stateis available (SvelteKit 2.12+). - Expecting a
$derivedto update when you mutate a non-reactive object it reads.
Source: “Pitfalls to avoid”.
Checks the agent runs before it says done
Example commands from the SvelteKit file (swap in your repo's own):
- Check:
npx svelte-check - Lint:
npm run lint - Tests:
npx vitest run - Build:
npx vite build
Run npx svelte-check, the linter, vitest run, and vite build. Report the results, including svelte-check warnings.
Before and after: Svelte: migrate a component from Svelte 4 syntax to Svelte 5 runes
Prompt card 40 · Stack: Svelte 5, SvelteKit 2 · Type: Refactor · Best model: a frontier reasoning model
What the card is for
Convert a legacy-syntax component (export let, $:, on:click, createEventDispatcher, slots) to runes, choosing correctly between $derived and $effect, and keep its public API working for parents during the transition.
Worked example from the card
Before: TagPicker.svelte
<script>
import { createEventDispatcher } from "svelte";
export let tags = [];
export let max = 3;
let selected = [];
const dispatch = createEventDispatcher();
$: remaining = max - selected.length;
$: if (selected.length === max) console.log("limit reached");
function toggle(tag) {
selected = selected.includes(tag) ? selected.filter(t => t !== tag) : [...selected, tag];
dispatch("change", selected);
}
</script>
{#each tags as tag}
<button on:click={() => toggle(tag)} disabled={!selected.includes(tag) && remaining === 0}>{tag}</button>
{/each}
<slot name="footer" {remaining} />
Parent: <TagPicker {tags} on:change={e => save(e.detail)}><p slot="footer" let:remaining>{remaining} left</p></TagPicker>
Expected AI output (abridged)
| export let tags/max | $props() with defaults |
| let selected | $state([]) |
| $: remaining | $derived (pure computation) |
| $: if (...) console.log | $effect (side effect) |
| dispatch("change") | onchange callback prop |
| on:click | onclick |
| named slot footer + let: | footer snippet prop taking remaining |
| {#each} without key | keyed by tag (tags are unique strings) |
Parents migrated in the same change: yes, so on:change becomes onchange.
After
<script lang="ts">
import type { Snippet } from "svelte";
let { tags = [], max = 3, onchange, footer }: {
tags?: string[];
max?: number;
onchange?: (selected: string[]) => void;
footer?: Snippet<[number]>;
} = $props();
let selected = $state<string[]>([]);
const remaining = $derived(max - selected.length);
$effect(() => {
if (selected.length === max) console.log("limit reached");
});
function toggle(tag: string) {
selected = selected.includes(tag) ? selected.filter(t => t !== tag) : [...selected, tag];
onchange?.(selected);
}
</script>
{#each tags as tag (tag)}
<button onclick={() => toggle(tag)} disabled={!selected.includes(tag) && remaining === 0}>{tag}</button>
{/each}
{@render footer?.(remaining)}
Parent:
<TagPicker {tags} onchange={save}>
{#snippet footer(remaining)}<p>{remaining} left</p>{/snippet}
</TagPicker>
Excerpt from card 40; the prompt itself is in the pack.
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and sveltekit.mdc in .cursor/rules/. Globs: svelte.config.js, **/*.svelte, **/*.svelte.ts, **/*.svelte.js, src/routes/**/*.ts, src/lib/**/*.ts, src/hooks*.ts.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/sveltekit.md for **/*.svelte, **/*.svelte.ts, src/routes/**/*.ts, src/lib/**/*.ts; PostToolUse hook: npx prettier --write <file>.
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in the SvelteKit app root. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.