DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Svelte: The Complete Guide to AI-Assisted Svelte Development

Cursor Rules for Svelte: The Complete Guide to AI-Assisted Svelte Development

Svelte is the framework where the compiler does so much for you that the bugs are almost always shape bugs, not behavior bugs. The component renders. The reactive statement runs. The store updates. And nothing in npm run dev warns you that the $: block you wrote depends on a value the compiler can't statically see, that the onMount callback you marked async is going to set state on an unmounted component when the user navigates mid-fetch, that the global store you import in seven components is leaking subscriptions because you never called the unsubscribe function the subscribe call returned, that the +page.svelte you wrote with a top-level fetch runs on the client first and re-runs after hydration, double-firing the request and shifting layout, or that the transition:fade you sprinkled on every list item makes a 200-row table take three full seconds to mount on a mid-tier Android. The app feels fast in development. It ships. Real users on real connections see jank, double-fetches, stale store values after navigation, and a memory profile that climbs every time someone opens and closes a modal.

Then you add an AI assistant.

Cursor and Claude Code were trained on a decade of Svelte content. Most of it is Svelte 3 syntax with <slot> everywhere, components that prop-drill state through five levels because the tutorial author hadn't introduced stores yet, $: doubled = count * 2 examples that hide the ten different things you should never put in a $: block, onMount(async () => { data = await fetch(...) }) with no abort signal and no unmount guard, <script lang="ts"> with export let user; and no type annotation at all, and +page.svelte files that call fetch from the markup as if SvelteKit's load function didn't exist. Ask for "a Svelte page that lists users," and you get prop-drilled props, a reactive block that fires on every keystroke even when nothing relevant changed, a fetch in onMount that races itself on navigation, and a transition on every <li>.

The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern Svelte (and SvelteKit) actually looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.

How Cursor Rules Work for Svelte Projects

Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended for anything bigger than a single app). For Svelte/SvelteKit I recommend modular rules so a marketing site's transition conventions don't bleed into a dashboard's data-loading constraints:

.cursor/rules/
  svelte-core.mdc        # stores, reactivity, lifecycle
  svelte-props.mdc       # typed props, generics, slot/snippet
  svelte-kit-load.mdc    # load functions, server vs universal
  svelte-kit-forms.mdc   # form actions, progressive enhancement
  svelte-motion.mdc      # transitions, animations, performance
Enter fullscreen mode Exit fullscreen mode

Frontmatter controls activation: globs: ["**/*.{svelte,ts}"] with alwaysApply: false. Now the rules.

Rule 1: Stores Over Prop Drilling — writable, readable, derived

The most common Svelte mistake is prop-drilling state through five layers because the AI was trained on small example components. Cursor writes <UserList user={user} /> then <UserRow user={user} /> then <UserAvatar user={user} /> and now any change to the User shape rewrites four files. The fix is the store: writable<User>(initial) lives in one module, every component that needs the value imports it and reads with $user, every component that needs to update calls user.set(...) or user.update(fn). derived composes stores into computed values. readable wraps external sources (a WebSocket, the system clock, the page visibility API) so subscribers get start/stop notifications for free.

The rule:

State that more than two components read or write goes in a store, not
in props. Define stores in `src/lib/stores/<name>.ts`, export the store
itself plus narrow read/write helpers when the API is non-trivial.

writable<T>(initial) for plain mutable state. update(fn) over set(value)
when the new value depends on the old. Never mutate the value in place
(`$user.name = 'x'`)  Svelte sees no change. Always call set/update.

derived([a, b], ([$a, $b]) => ...) for computed values. Never compute
in markup with `$:` when the same computation is used in multiple
components  derive once, subscribe everywhere.

readable<T>(initial, (set) => { ...; return () => cleanup(); }) for
external sources. The teardown function fires when the last subscriber
unsubscribes  wire WebSockets, intervals, event listeners through this.

Auto-subscription with `$store` only inside .svelte files. In .ts files
use `get(store)` for one-shot reads or `store.subscribe(fn)` and capture
the unsubscribe. Never let a manual subscribe leak.

Never put non-serializable values (DOM nodes, class instances with
methods) in stores that cross route boundaries  SvelteKit serializes
them and you lose identity.
Enter fullscreen mode Exit fullscreen mode

Before — prop-drilled state, mutated in place, no single source of truth:

<!-- App.svelte -->
<script>
  let user = { name: 'Ada', plan: 'free' };
</script>
<Layout {user}>
  <Sidebar {user} />
  <Main {user} on:upgrade={() => user.plan = 'pro'} />
</Layout>

<!-- Main.svelte -->
<script>
  export let user;
  function upgrade() {
    user.plan = 'pro'; // mutation — no re-render in Sidebar
  }
</script>
<button on:click={upgrade}>Upgrade</button>
Enter fullscreen mode Exit fullscreen mode

user.plan = 'pro' mutates the object in place. The parent's reference doesn't change, so Svelte's $:-backed reactivity in Sidebar never fires. Every layer takes a user prop just to forward it.

After — store, derived value, auto-subscription, no mutation:

<!-- src/lib/stores/user.ts -->
<script context="module" lang="ts">
  import { writable, derived } from 'svelte/store';
  export const user = writable<{ name: string; plan: 'free' | 'pro' }>(
    { name: 'Ada', plan: 'free' }
  );
  export const isPaid = derived(user, ($u) => $u.plan === 'pro');
</script>

<!-- Main.svelte -->
<script lang="ts">
  import { user } from '$lib/stores/user';
  function upgrade() {
    user.update((u) => ({ ...u, plan: 'pro' }));
  }
</script>
<button on:click={upgrade}>Upgrade</button>

<!-- Sidebar.svelte -->
<script lang="ts">
  import { user, isPaid } from '$lib/stores/user';
</script>
<p>{$user.name} {#if $isPaid}{/if}</p>
Enter fullscreen mode Exit fullscreen mode

update returns a new object — every subscriber re-renders. Sidebar and Main import directly; no prop-forwarding through Layout. derived computes isPaid once.

Rule 2: Reactive Declarations With $: — Pure, Statically Analyzable, No Side Effects

Svelte's $: is its most powerful and most misused feature. It runs whenever any referenced reactive variable changes — but the compiler only tracks what it can statically see in the dependency graph. Cursor writes $: result = compute(items) where compute reads from a closure variable the compiler can't see, and the block silently never re-runs. Worse, Cursor uses $: for side effects: $: console.log(count), $: localStorage.setItem('x', value), $: fetch(url). The block fires on every render of every dependency, including ones you didn't realize were dependencies — you get triple writes to localStorage, three fetches per keystroke, and a console flood that hides the actual bug.

The rule:

`$:` is for derived values that are pure functions of their declared
dependencies. `$: doubled = count * 2`. `$: filtered = items.filter(matches)`.
The right-hand side must be a pure expression  no fetch, no localStorage,
no logging, no setState-style writes to other reactive variables.

For side effects on change, use the explicit pattern:
`$: { if (value !== prev) { sideEffect(value); prev = value; } }`  and
even better, lift the side effect into an effect/store subscription
outside the component, or into onMount/afterUpdate with explicit guards.

Never do destructive work in `$:` (`$: items.push(x)`). The block runs
multiple times on the same input  you'll duplicate effects. Compute,
don't mutate.

Multi-statement reactive blocks (`$: { ... }`) declare ALL their
dependencies in the first executed line. If the first branch returns
early, dependencies in the unreached branch are not tracked. Read the
inputs at the top, then branch.

For Svelte 5 projects, prefer `$state`/`$derived`/`$effect` runes
over `$:`  they have explicit dependency tracking and clearer side-
effect semantics.
Enter fullscreen mode Exit fullscreen mode

Before — side effect in $:, hidden dependency, triple-firing:

<script>
  export let userId;
  let user = null;
  let count = 0;

  $: fetchUser(userId); // fires on every count change too — count is a closure dep
  $: console.log('user changed', user); // logs on render, not on user change
  $: items.push({ userId, count }); // mutates on every reactive tick

  async function fetchUser(id) {
    const r = await fetch(`/api/users/${id}`);
    user = await r.json();
    count++; // re-triggers $: fetchUser → infinite loop
  }
</script>
Enter fullscreen mode Exit fullscreen mode

fetchUser mutates count, which is a dependency of the same $: block, which re-fires the fetch. items.push runs on every render. The console "log on change" actually logs on every render, including ones where user didn't change.

After — $: for pure derivation, side effects in onMount + reactive guard:

<script lang="ts">
  import { onMount } from 'svelte';
  export let userId: string;
  let user: User | null = null;
  let prevUserId = '';

  $: greeting = user ? `Hello ${user.name}` : 'Loading…'; // pure derivation

  $: if (userId !== prevUserId) {
    prevUserId = userId;
    void loadUser(userId); // explicit, guarded, single fire per change
  }

  async function loadUser(id: string) {
    const r = await fetch(`/api/users/${id}`);
    if (!r.ok) return;
    user = await r.json();
  }
</script>

<p>{greeting}</p>
Enter fullscreen mode Exit fullscreen mode

$: greeting = ... is pure — recomputes when user changes, never fires a network call. The side effect lives behind an explicit if (changed) guard so it fires once per real change, not once per reactive tick.

Rule 3: Lifecycle Discipline — onMount Async Safety, onDestroy Cleanup

onMount(async () => { data = await fetch(...) }) looks innocent and is the source of most "state set on unmounted component" bugs in Svelte. The component mounts, the fetch starts, the user navigates away, the component unmounts, the fetch resolves, the assignment runs against a destroyed instance. In dev you see a stale render; in prod you see memory pressure as event listeners and intervals never get cleaned up. Cursor doesn't write onDestroy cleanup unless explicitly asked because the training examples are toy components that never unmount. Same problem with setInterval, addEventListener, IntersectionObserver, WebSocket subscriptions — start in onMount, forget in onDestroy, leak forever.

The rule:

`onMount` MUST NOT be marked `async`. The Svelte runtime treats the
return value as a cleanup function  an async onMount returns a
Promise, which it can't use as cleanup. Define an inner async function
and call it: `onMount(() => { void load(); })`.

Async work started in onMount uses an AbortController. Store the
controller, pass `signal` to fetch, abort in onDestroy / the cleanup
return. Race-protected: `if (controller.signal.aborted) return;`
before any state assignment.

Every onMount that allocates a resource (interval, listener, observer,
subscription, websocket) RETURNS a cleanup function. The return-from-
onMount form is preferred over a separate onDestroy because it keeps
allocate and free side-by-side.

Manual store subscriptions (`store.subscribe(fn)`) outside .svelte files
ALWAYS capture the unsubscribe and call it. Inside .svelte files use
`$store` autosubscribe — never manual subscribe.

`beforeUpdate` / `afterUpdate` are escape hatches. Reach for them only
when DOM measurement requires it (focus management, scroll position
preservation). Never for "setState on data change" — that is `$:`.

For Svelte 5: prefer `$effect` with its automatic cleanup return — it
subsumes onMount + onDestroy for most cases.
Enter fullscreen mode Exit fullscreen mode

Before — async onMount, no abort, no cleanup, leaked interval:

<script>
  import { onMount } from 'svelte';
  export let userId;
  let user = null;

  onMount(async () => {
    const r = await fetch(`/api/users/${userId}`);
    user = await r.json(); // fires even if the user navigated away

    setInterval(() => poll(userId), 5000); // never cleared
    window.addEventListener('focus', () => poll(userId)); // never removed
  });
</script>
Enter fullscreen mode Exit fullscreen mode

If the route changes during await, the assignment runs on an unmounted component and React-style warnings flood the console. The interval and listener accumulate every time the user visits this route.

After — sync onMount, AbortController, cleanup return:

<script lang="ts">
  import { onMount } from 'svelte';
  export let userId: string;
  let user: User | null = null;

  onMount(() => {
    const controller = new AbortController();

    (async () => {
      try {
        const r = await fetch(`/api/users/${userId}`, { signal: controller.signal });
        if (controller.signal.aborted) return;
        user = await r.json();
      } catch (err) {
        if ((err as Error).name === 'AbortError') return;
        throw err;
      }
    })();

    const interval = setInterval(() => poll(userId), 5000);
    const onFocus = () => poll(userId);
    window.addEventListener('focus', onFocus);

    return () => {
      controller.abort();
      clearInterval(interval);
      window.removeEventListener('focus', onFocus);
    };
  });
</script>
Enter fullscreen mode Exit fullscreen mode

Mount-time allocation and unmount-time cleanup live in the same block. The fetch aborts on navigation. The interval and listener are released. No "set state on unmounted component" warnings.

Rule 4: Typed Props With TypeScript Generics — export let Without : T Is a Bug

export let user; ships an any prop. The component compiles, the parent passes whatever, and the bug is the third nested field access at runtime. Cursor writes untyped exports because the Svelte 3 documentation samples don't include types. The fix is export let user: User, plus generics for components that work over arbitrary item types (a <List items={users} let:item>{item.name}</List> should be generic in the item type so the slot prop is typed). Svelte supports generics via <script lang="ts" generics="T"> — most AI assistants don't reach for it because it's a relatively recent addition, so they fall back to any-typed slot props.

The rule:

Every `export let prop` has a TypeScript type annotation. Optional
props use `export let prop: T | undefined = undefined` (not bare
`export let prop`). Default values use `export let prop: T = default`
and the type must include the default's type.

Required vs optional is encoded in the type, not in JSDoc. Svelte's
type-checker emits errors for missing required props at the call site.

Components that work over arbitrary item types use the `generics`
attribute: `<script lang="ts" generics="T extends { id: string }">`.
Slot props are then typed via `let:item` on the consumer side without
casts.

Event payloads are typed via `createEventDispatcher<{ select: User;
delete: { id: string } }>()`. No untyped `dispatch('select', user)`.

`$$Props`, `$$Events`, `$$Slots` interfaces declare the component's
public surface when the inferred surface is wrong (rare — prefer
inference). Used at the top of the .svelte file.

No `export let` without type. ESLint or `svelte-check --fail-on-warnings`
in CI catches it.
Enter fullscreen mode Exit fullscreen mode

Before — untyped props, untyped slot, untyped event:

<!-- List.svelte -->
<script>
  import { createEventDispatcher } from 'svelte';
  export let items;
  const dispatch = createEventDispatcher();
</script>

<ul>
  {#each items as item}
    <li on:click={() => dispatch('select', item)}>
      <slot {item} />
    </li>
  {/each}
</ul>

<!-- Parent.svelte -->
<List items={users} on:select={(e) => loadUser(e.detail.id)} let:item>
  {item.name} <!-- item is `any` — no autocomplete, no error if you typo .nmae -->
</List>
Enter fullscreen mode Exit fullscreen mode

items is any[]. e.detail is any. item in the slot is any. Any rename of User.name doesn't surface as a type error.

After — generics, typed dispatch, typed slot:

<!-- List.svelte -->
<script lang="ts" generics="T extends { id: string }">
  import { createEventDispatcher } from 'svelte';
  export let items: T[];
  export let emptyLabel: string = 'No items';
  const dispatch = createEventDispatcher<{ select: T; delete: { id: string } }>();
</script>

{#if items.length === 0}
  <p>{emptyLabel}</p>
{:else}
  <ul>
    {#each items as item (item.id)}
      <li>
        <button on:click={() => dispatch('select', item)}>
          <slot {item} />
        </button>
      </li>
    {/each}
  </ul>
{/if}

<!-- Parent.svelte -->
<script lang="ts">
  import type { User } from '$lib/types';
  let users: User[] = [];
  function onSelect(e: CustomEvent<User>) { /* e.detail is User */ }
</script>

<List items={users} on:select={onSelect} let:item>
  {item.name} <!-- item is User; `item.nmae` is a compile error -->
</List>
Enter fullscreen mode Exit fullscreen mode

generics="T extends { id: string }" makes the component reusable and the slot prop strongly typed at the call site. Renaming User.name errors at every consumer. e.detail is User, not any.

Rule 5: SvelteKit Load Functions — Server vs Universal, Never Fetch in Components

SvelteKit gives you +page.ts (universal — runs on server during SSR, then on client during navigation) and +page.server.ts (server-only — always runs on the server, can read databases, secrets, files). Cursor reaches for +page.svelte with a top-level <script> fetch, which is the worst of all worlds: it doesn't run during SSR (so no SEO, no fast first paint), it runs on every client navigation re-mount (so duplicate requests), and it can't access secrets without exposing them. The fix is mechanical: data loading lives in a load function. Server-only data lives in +page.server.ts. Public data that benefits from CDN caching lives in +page.ts. Components receive data via the data prop.

The rule:

Data loading happens in `load()` functions, never in `+page.svelte`
component bodies. No top-level `fetch` in component scripts. No
`onMount(() => fetch(...))` for data the page needs to render.

`+page.server.ts` for: anything that touches the database, anything
using a secret/env var, anything that calls an internal-only API,
anything that needs cookies/headers. Never `import` server-only modules
from `+page.ts`  the build will leak them to the client.

`+page.ts` for: public APIs, data safe to cache at the edge, data the
client can re-fetch on subsequent navigations without a server round-trip.

`load` functions use the supplied `fetch` argument, NOT the global
fetch. The supplied fetch handles SSR cookies, internal routing, and
relative URLs correctly.

Streaming: return promises in nested fields of the load object for
non-critical data. The page renders with the critical data; nested
promises stream in via `{#await}`.

Errors in load throw `error(404, 'Not found')` from `@sveltejs/kit` 
never `return { error: ... }`. Redirects throw `redirect(303, '/login')`.

`depends('app:users')` + `invalidate('app:users')` for client-side
re-fetches. Never re-implement caching in components.
Enter fullscreen mode Exit fullscreen mode

Before — fetch in component, runs on client only, double-fires on nav:

<!-- src/routes/users/[id]/+page.svelte -->
<script>
  import { page } from '$app/stores';
  import { onMount } from 'svelte';
  let user = null;
  onMount(async () => {
    const r = await fetch(`/api/users/${$page.params.id}`);
    user = await r.json();
  });
</script>

{#if user}<h1>{user.name}</h1>{:else}Loading…{/if}
Enter fullscreen mode Exit fullscreen mode

No SSR data — search engines see "Loading…". Every client navigation re-mounts the component and re-fetches. If the API requires a session cookie, the fetch may not include it correctly during transitions.

After — +page.server.ts, typed data prop, error helper:

// src/routes/users/[id]/+page.server.ts
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db';

export const load: PageServerLoad = async ({ params, depends }) => {
  depends('app:user');
  const user = await db.users.findById(params.id);
  if (!user) throw error(404, 'User not found');
  return {
    user,
    recentOrders: db.orders.findRecent(params.id), // streamed — non-critical
  };
};
Enter fullscreen mode Exit fullscreen mode
<!-- src/routes/users/[id]/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';
  export let data: PageData;
</script>

<h1>{data.user.name}</h1>

{#await data.recentOrders}
  <p>Loading orders…</p>
{:then orders}
  <ul>{#each orders as o (o.id)}<li>{o.total}</li>{/each}</ul>
{/await}
Enter fullscreen mode Exit fullscreen mode

data.user is server-rendered — search engines see the name. recentOrders streams in. The 404 is a real HTTP 404. No double-fetch on navigation.

Rule 6: Form Actions Over Client-Side Fetch — Progressive Enhancement Built-In

The default Cursor pattern for "submit a form" is <form on:submit|preventDefault={handle}> with a fetch inside handle. It works when JS is loaded, breaks completely when it isn't (mobile network failures, ad blockers, the brief window before hydration), and re-implements every part of form handling — CSRF, validation errors, redirects, optimistic updates — by hand. SvelteKit form actions solve this. A +page.server.ts exports an actions object. A <form method="POST" action="?/createUser" use:enhance> posts to that action. With JS, use:enhance intercepts and updates the UI without reload. Without JS, the browser does a full POST and the page re-renders. One source of truth, two delivery mechanisms.

The rule:

For mutations triggered by a form, use SvelteKit form actions:
`export const actions: Actions = { default: async ({ request }) => {...} }`
in `+page.server.ts`. The form is `<form method="POST" use:enhance>`.

Validation lives in the action. Failures return `fail(400, { errors,
values })`  the form re-renders with the errors and the previously
entered values via `form` prop on the page.

Redirects after successful mutation use `throw redirect(303, '/path')`
from the action  never client-side `goto()` after a fetch.

`use:enhance` is the default. Its callback receives `({ formData,
formElement, cancel })` for optimistic UI; the returned function
receives `({ result, update })` for post-submit handling. `update()`
re-runs `load`. Reach for `applyAction` only when the page should
refresh from the action's return value.

Multiple actions on one page use named actions:
`actions: { create: ..., delete: ... }` and
`<button formaction="?/delete">`.

For non-form mutations (drag-drop, in-place edit), prefer a `+server.ts`
endpoint over an unrelated form. Don't shoehorn JSON APIs into form
actions; don't shoehorn forms into bespoke fetch handlers.

CSRF is handled by SvelteKit's origin check for form actions  do
not disable. Custom JSON endpoints need their own CSRF strategy.
Enter fullscreen mode Exit fullscreen mode

Before — hand-rolled fetch, no validation surface, no progressive enhancement:

<script>
  let email = '', error = '';
  async function submit() {
    const r = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });
    if (!r.ok) {
      error = (await r.json()).message;
      return;
    }
    location.href = '/welcome';
  }
</script>

<form on:submit|preventDefault={submit}>
  <input bind:value={email} />
  {#if error}<p>{error}</p>{/if}
  <button>Sign up</button>
</form>
Enter fullscreen mode Exit fullscreen mode

If JS hasn't loaded, clicking the button does nothing. CSRF, redirect handling, error mapping — all hand-rolled. email is lost on validation error if the user reloads.

After — form action, use:enhance, progressive enhancement, fail() for validation:

// +page.server.ts
import type { Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';

const SignupSchema = z.object({ email: z.string().email() });

export const actions: Actions = {
  default: async ({ request }) => {
    const data = Object.fromEntries(await request.formData());
    const parsed = SignupSchema.safeParse(data);
    if (!parsed.success) {
      return fail(400, { values: data, errors: parsed.error.flatten().fieldErrors });
    }
    await db.users.create(parsed.data);
    throw redirect(303, '/welcome');
  },
};
Enter fullscreen mode Exit fullscreen mode
<!-- +page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';
  export let form: ActionData;
</script>

<form method="POST" use:enhance>
  <input name="email" value={form?.values?.email ?? ''} />
  {#if form?.errors?.email}<p>{form.errors.email[0]}</p>{/if}
  <button>Sign up</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Works without JS (full POST → server validates → re-renders with errors). Works with JS (use:enhance intercepts, no reload, same code path). Validation in one place. Redirect handled by SvelteKit. CSRF protected.

Rule 7: Slot/Snippet Composition Over God Components

Cursor likes a single <UserCard> that takes 12 props — showAvatar, showBadge, compact, interactive, onSelect, onDelete, selectionMode, theme. Six months later it has 22 props, three of them mutually exclusive, and every consumer either passes the wrong combination or copy-pastes the entire component into a new one. The Svelte answer is composition: a small core component that exposes named slots (Svelte 4) or snippet props (Svelte 5), and the consumer wires together the variant they want. <UserCard let:user><Avatar {user} /><Name {user} /></UserCard> is more code at the call site but it's all explicit, all type-checked, and there are no boolean prop combinations to test.

The rule:

A component with more than ~5 props that mainly toggles structural
variants is a smell. Refactor to slots/snippets that the parent fills.

Named slots over boolean props for layout: `<slot name="header">`,
`<slot name="footer">` instead of `<Card showHeader showFooter>`.

Slot props (`<slot {item}>`) propagate data to the consumer  used
when the parent needs to render derived data. Type-check via the
`generics` attribute (Rule 4).

Default slot for the primary content. Reserve named slots for
peripheral structure  every named slot is a place the parent has
to learn about.

Svelte 5: prefer `Snippet` props over slots. Snippets are first-class
typed values: `import type { Snippet } from 'svelte'; export let row:
Snippet<[item: User]>`. Render with `{@render row(item)}`. Slot fallback
content becomes the snippet's default.

A "God component" controlled by a `mode` enum (`mode="compact" |
"detailed" | "kanban"`) is two or three components in disguise. Split
them. Share a layout primitive if needed; do not share an
overloaded one.
Enter fullscreen mode Exit fullscreen mode

Before — boolean explosion, mutually exclusive flags, untestable matrix:

<!-- UserCard.svelte -->
<script>
  export let user;
  export let showAvatar = true;
  export let showBadge = false;
  export let compact = false;
  export let interactive = false;
  export let theme = 'light';
  export let onSelect = null;
  // ...nine more
</script>

<div class="card {compact ? 'compact' : ''} {theme}">
  {#if showAvatar}<img src={user.avatar} />{/if}
  <h2>{user.name}</h2>
  {#if showBadge}<span class="badge">{user.plan}</span>{/if}
  {#if interactive}<button on:click={() => onSelect?.(user)}>Select</button>{/if}
</div>
Enter fullscreen mode Exit fullscreen mode

The matrix of { showAvatar, showBadge, compact, interactive, theme } is 32 combinations. Each new requirement adds a flag and another two-power doubling.

After — small primitive, slots for structure, composition at the call site:

<!-- UserCard.svelte -->
<script lang="ts">
  import type { User } from '$lib/types';
  export let user: User;
</script>

<article class="card">
  <slot name="media" {user} />
  <div class="body">
    <slot {user}>
      <h2>{user.name}</h2>
    </slot>
  </div>
  <slot name="actions" {user} />
</article>

<!-- Consumer: sidebar (compact, no actions) -->
<UserCard {user} let:user>
  <strong>{user.name}</strong>
</UserCard>

<!-- Consumer: admin list (avatar + plan badge + actions) -->
<UserCard {user} let:user>
  <svelte:fragment slot="media"><Avatar {user} /></svelte:fragment>
  <h2>{user.name} <PlanBadge plan={user.plan} /></h2>
  <svelte:fragment slot="actions">
    <button on:click={() => select(user)}>Select</button>
    <button on:click={() => remove(user)}>Delete</button>
  </svelte:fragment>
</UserCard>
Enter fullscreen mode Exit fullscreen mode

UserCard has one prop. Variants are expressed by what the consumer passes into the slots — typed, explicit, composable. No mode enum, no flag matrix.

Rule 8: Transition and Animation Discipline — Not on Every Element

Svelte's transitions are gorgeous and ergonomic — transition:fade on any element, done. Cursor sprinkles them everywhere because the docs make them look free. They aren't. Every transition allocates a JS-driven animation that runs on the main thread per element. A list of 200 items with transition:fade on each <li> mounts with 200 simultaneous animations, blocks the main thread, and your time-to-interactive on a mid-tier Android collapses. The animate:flip directive on a sortable list does the right thing but only when the keyed {#each (item.id)} is correct — without the key, every item re-mounts and the animation is meaningless.

The rule:

Transitions are for individually-meaningful UI changes  modal open,
toast appear, route segment swap. NOT for "every item in this list
fades in on first render."

For lists, use `animate:flip` on items inside a `{#each items as item
(item.id)}` (the key is mandatory). flip animates moves between sorts/
filters; it does NOT fade items in/out. Combine with a single parent
`transition:fade` if the whole list should appear together.

`local` modifier (`transition:fade|local`) when the transition should
only fire on local block changes, not on parent route transitions. Most
list-item transitions should be local  otherwise they re-fire on every
navigation.

Custom transitions return `{ duration, css?, tick? }`. Prefer `css`
(GPU-composited) over `tick` (main-thread JS per frame). Reach for
`tick` only for non-CSS-animatable properties (canvas, scrollTop).

Reduced motion: respect `prefers-reduced-motion`. Either gate
transitions behind a media query check (use `@sveltejs/kit`'s no-helper
pattern with a store) or set `duration: 0` when the media query matches.
Never ship a UI that ignores the OS setting.

For long lists (>50 items), virtualize (svelte-virtual-list,
@tanstack/svelte-virtual) — transitions on a virtualized list animate
only the visible window, which is what you wanted anyway.
Enter fullscreen mode Exit fullscreen mode

Before — transition on every list item, no key, no reduced-motion check:

<script>
  import { fade } from 'svelte/transition';
  export let users; // 500 items
</script>

<ul>
  {#each users as user}
    <li transition:fade={{ duration: 300 }}>{user.name}</li>
  {/each}
</ul>
Enter fullscreen mode Exit fullscreen mode

500 simultaneous fades on mount. Without (user.id) key, any sort change re-mounts every item — 500 more fades. Users with reduced-motion enabled get the full animation.

After — flip for moves, single parent fade, local + reduced-motion:

<script lang="ts">
  import { fade } from 'svelte/transition';
  import { flip } from 'svelte/animate';
  import { reducedMotion } from '$lib/stores/motion';
  export let users: User[];

  $: duration = $reducedMotion ? 0 : 200;
</script>

<ul transition:fade|local={{ duration }}>
  {#each users as user (user.id)}
    <li animate:flip={{ duration }}>{user.name}</li>
  {/each}
</ul>
Enter fullscreen mode Exit fullscreen mode
// src/lib/stores/motion.ts
import { readable } from 'svelte/store';
import { browser } from '$app/environment';

export const reducedMotion = readable(false, (set) => {
  if (!browser) return;
  const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
  set(mq.matches);
  const onChange = (e: MediaQueryListEvent) => set(e.matches);
  mq.addEventListener('change', onChange);
  return () => mq.removeEventListener('change', onChange);
});
Enter fullscreen mode Exit fullscreen mode

One transition on the container, not 500. animate:flip handles re-orderings smoothly with a stable key. prefers-reduced-motion is honored.

The Complete .cursorrules File

Drop this in the repo root. Cursor and Claude Code both pick it up.

# Svelte / SvelteKit — Production Patterns

## Stores Over Prop Drilling
- State read/written by more than two components lives in a store
  (`src/lib/stores/<name>.ts`), not in props.
- writable(initial) for plain mutable state. update(fn) over set when
  the new value depends on the old. Never mutate in place.
- derived([a, b], ...) for computed values used in multiple places.
- readable(initial, (set) => {...; return cleanup}) for external sources
  (WebSocket, interval, media query). Cleanup fires on last unsubscribe.
- `$store` autosubscribe inside .svelte. In .ts use get(store) for one-
  shot reads; manual subscribe MUST capture and call the unsubscribe.
- No non-serializable values in stores that cross route boundaries.

## Reactive Declarations With $:
- `$:` is for pure derivations of declared dependencies. No fetch, no
  localStorage, no logging, no setState-style writes.
- For side effects on change: explicit `if (value !== prev) {...; prev =
  value }` guard, OR lift to onMount / store subscription.
- Multi-statement reactive blocks read all dependencies at the top —
  early returns hide deps from the compiler.
- Svelte 5: prefer $state / $derived / $effect runes over $:.

## Lifecycle Discipline
- onMount is NOT async. Define inner async function; call it.
- Async work in onMount uses AbortController; signal to fetch; abort in
  cleanup. Guard with `if (signal.aborted) return;` before state writes.
- onMount that allocates (interval, listener, observer, subscription)
  RETURNS a cleanup function — keep allocate and free side-by-side.
- Manual store.subscribe outside .svelte ALWAYS captures unsubscribe.
- beforeUpdate/afterUpdate only for DOM measurement (focus, scroll).
  Never for "react to data change" — that is $: or $effect.
- Svelte 5: prefer $effect with its automatic cleanup return.

## Typed Props With Generics
- Every `export let prop` has a type annotation. Optional: `T |
  undefined = undefined`. Default: `T = default` with matching type.
- Components over arbitrary item types use
  `<script lang="ts" generics="T extends ...">`.
- Event payloads typed via createEventDispatcher<{ event: T }>().
- $$Props/$$Events/$$Slots only when inferred surface is wrong.
- svelte-check --fail-on-warnings in CI catches untyped props.

## SvelteKit Load Functions
- Data loading lives in load(). No top-level fetch in component scripts.
  No onMount(() => fetch(...)) for page-render data.
- +page.server.ts for DB, secrets, internal APIs, cookies/headers.
  Never import server-only modules from +page.ts.
- +page.ts for public/CDN-cacheable data.
- Use the supplied `fetch` argument, never global fetch.
- Stream non-critical data via promises in nested fields + {#await}.
- Errors: `throw error(404, 'msg')`. Redirects: `throw redirect(303, '/path')`.
- depends('app:x') + invalidate('app:x') for client re-fetch.

## Form Actions Over Client-Side Fetch
- Form mutations: actions in +page.server.ts + <form method="POST"
  use:enhance>. Works without JS, enhanced with JS.
- Validation in the action; failures `return fail(400, { errors, values })`.
- Successful mutation: `throw redirect(303, '/path')` from the action.
- Multiple actions: named actions + `formaction="?/name"`.
- Non-form mutations: +server.ts endpoints. Don't shoehorn.
- CSRF handled by SvelteKit's origin check; do not disable.

## Slot/Snippet Composition
- Components with >5 mostly-structural props are a smell — refactor
  to slots/snippets the parent fills.
- Named slots over boolean props for layout: <slot name="header" />
  not <Card showHeader />.
- Slot props (let:item) propagate data; type via `generics` attribute.
- Svelte 5: prefer Snippet<[T]> props over slots — first-class typed.
- A `mode` enum component is N components pretending to be one. Split.

## Transition / Animation Discipline
- Transitions for individually-meaningful changes (modal, toast, route
  swap). NOT for "every list item fades in on mount."
- Lists: `animate:flip` inside `{#each items as item (item.id)}` — key
  is mandatory. Single parent transition for the whole list.
- `|local` modifier prevents re-firing on parent route transitions.
- Custom transitions: prefer `css` (GPU) over `tick` (main thread JS).
- Respect prefers-reduced-motion — gate via store, set duration: 0
  when matched.
- Lists >50 items: virtualize (svelte-virtual-list, tanstack-svelte-virtual).
Enter fullscreen mode Exit fullscreen mode

End-to-End Example: A SvelteKit User Profile Page

Without rules: untyped props, fetch in onMount, prop drilling, transition on every row, hand-rolled form.

<!-- +page.svelte -->
<script>
  import { onMount } from 'svelte';
  import { page } from '$app/stores';
  import { fade } from 'svelte/transition';
  let user = null, orders = [];
  onMount(async () => {
    user = await (await fetch(`/api/users/${$page.params.id}`)).json();
    orders = await (await fetch(`/api/users/${$page.params.id}/orders`)).json();
  });
  async function rename() {
    await fetch(`/api/users/${$page.params.id}`, {
      method: 'PATCH', body: JSON.stringify({ name: user.name }),
    });
  }
</script>

{#if user}
  <h1>{user.name}</h1>
  <input bind:value={user.name} />
  <button on:click={rename}>Save</button>
  <ul>
    {#each orders as o}<li transition:fade>{o.total}</li>{/each}
  </ul>
{/if}
Enter fullscreen mode Exit fullscreen mode

With rules: server load, typed data prop, form action, single transition, keyed list with flip.

// src/routes/users/[id]/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { db } from '$lib/server/db';

const RenameSchema = z.object({ name: z.string().min(1).max(80) });

export const load: PageServerLoad = async ({ params, depends }) => {
  depends('app:user');
  const user = await db.users.findById(params.id);
  if (!user) throw error(404, 'User not found');
  return { user, orders: db.orders.findRecent(params.id) };
};

export const actions: Actions = {
  rename: async ({ request, params }) => {
    const parsed = RenameSchema.safeParse(
      Object.fromEntries(await request.formData()),
    );
    if (!parsed.success) {
      return fail(400, { errors: parsed.error.flatten().fieldErrors });
    }
    await db.users.update(params.id, parsed.data);
    throw redirect(303, `/users/${params.id}`);
  },
};
Enter fullscreen mode Exit fullscreen mode
<!-- src/routes/users/[id]/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import { fade } from 'svelte/transition';
  import { flip } from 'svelte/animate';
  import { reducedMotion } from '$lib/stores/motion';
  import type { ActionData, PageData } from './$types';
  export let data: PageData;
  export let form: ActionData;

  $: duration = $reducedMotion ? 0 : 200;
</script>

<h1>{data.user.name}</h1>

<form method="POST" action="?/rename" use:enhance>
  <input name="name" value={data.user.name} />
  {#if form?.errors?.name}<p>{form.errors.name[0]}</p>{/if}
  <button>Save</button>
</form>

{#await data.orders}
  <p>Loading orders…</p>
{:then orders}
  <ul transition:fade|local={{ duration }}>
    {#each orders as o (o.id)}
      <li animate:flip={{ duration }}>{o.total}</li>
    {/each}
  </ul>
{/await}
Enter fullscreen mode Exit fullscreen mode

User data is server-rendered (SEO, fast first paint). Orders stream in. The form works without JS, enhances with it. Validation errors round-trip with the previous values. The list has one transition, not N. Reduced-motion is honored. data and form are fully typed.

Get the Full Pack

These eight rules cover the Svelte and SvelteKit patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — store-backed, typed, server-loaded, action-mutated, composition-first, motion-disciplined Svelte, without having to re-prompt.

If you want the expanded pack — these eight plus rules for SvelteKit hooks (handle, handleFetch, server hooks for auth), endpoint conventions for +server.ts, runed Svelte 5 patterns, Tailwind + Svelte component organization, Playwright component tests, accessibility patterns specific to Svelte (focus management, ARIA in transitions, keyboard nav in custom widgets), Vitest + svelte-testing-library conventions, and the deployment patterns I use on production SvelteKit services — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Svelte you would actually merge.

Top comments (0)