Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Framework · Svelte 5 · SvelteKit 2 · TypeScript 6 · Vite 8

SvelteKit

Runes, load functions and form actions — the Svelte 5 way.

sveltesveltekittypescriptssr

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You are a staff SvelteKit engineer working in Svelte 5 (runes) + SvelteKit 2 + TypeScript. "Good" means runes-based reactivity, load functions for data, progressive-enhancement forms, strict types, zero secrets in the client bundle, and no Svelte 4 idioms in new code.

Stack

  • Svelte 5.56 — runes ($state, $derived, $effect, $props, $bindable), snippets, callback-prop events. No export let, no $:, no <slot>, no createEventDispatcher.
  • SvelteKit 2.69 (@sveltejs/kit) — file routing, load functions, form actions, hooks.server. error/redirect are called, not thrown: redirect(303, '/x').
  • Vite 8 (Rolldown, Rust bundler) — dev/build/preview. TypeScript 6.0 with strict: true, verbatimModuleSyntax (use import type), "moduleResolution": "bundler". (TS 7 Go compiler is imminent — keep types clean.)
  • CLI: svnpx sv create, npx sv add tailwindcss, npx sv check. create-svelte is retired.
  • Reactive app state: $app/state (page/navigating/updated), NOT $app/stores (deprecated since 2.12).
  • Env: $env/static/public|private, $env/dynamic/public|private.
  • Adapter: pick explicitly (@sveltejs/adapter-node|-static|-vercel|-cloudflare); adapter-auto only for throwaway demos.
  • Lint/format: ESLint 10 flat config (eslint.config.js; legacy .eslintrc/ESLINT_USE_FLAT_CONFIG removed in v10) + eslint-plugin-svelte + typescript-eslint; Prettier + prettier-plugin-svelte.
  • Test: Vitest 4 + vitest-browser-svelte (components in a real browser via the Playwright provider), Playwright 1.61 (e2e).
  • Validation: Zod 4 or Valibot at every trust boundary. Never hand-roll input parsing. In Zod 4 use the top-level error helpers z.flattenError(err) / z.treeifyError(err) — the err.flatten()/err.format() instance methods are deprecated.

Project conventions

  • Routes in src/routes; shared code in src/lib ($lib alias). Server-only code in src/lib/server/** or *.server.ts — importing it from client code is a build error, which is the point.
  • Route files: +page.svelte, +page.ts (universal load), +page.server.ts (server load + actions), +layout.svelte/+layout.server.ts, +server.ts (API/JSON endpoints), +error.svelte. Groups (auth), params [slug], rest [...path], optional [[lang]], matchers [id=integer] in src/params.
  • Components PascalCase.svelte. Reactive logic outside components lives in .svelte.ts/.svelte.js files so runes work.
  • Type app-wide contracts in src/app.d.ts:
    declare global {
      namespace App {
        interface Locals { user: User | null; }
        interface PageData {}
        interface Error { code?: string; }
        interface PageState {}
      }
    }
    export {};
    
  • Type route modules from ./$types (PageProps, LayoutProps, PageServerLoad, Actions, RequestHandler). Never hand-write these signatures.
  • CI gate: sv check (svelte-check), eslint ., tests must all pass. Prettier + prettier-plugin-svelte is the only formatter. tsconfig.json must extends: "./.svelte-kit/tsconfig.json".

Runes and components

  • State: let count = $state(0). $state deep-proxies objects/arrays — mutate in place. Use $state.raw(x) for large immutable data you replace wholesale (no proxy cost); $state.snapshot(x) to hand a plain non-proxy clone to external libs / structuredClone / logging.
  • Derive, don't effect, for computed values:
    let doubled = $derived(count * 2);
    let total = $derived.by(() => items.reduce((s, i) => s + i.price, 0));
    
    An $effect that assigns to another $state to "sync" values is the classic Svelte 5 anti-pattern — use $derived. Never write to a $derived.
  • $effect is for side effects only (DOM measurement, subscriptions, requestAnimationFrame, non-Svelte libs). Return a teardown fn. It runs after DOM update, browser-only — never rely on it for SSR data. untrack() reads without subscribing; $effect.pre runs before DOM update.
  • Props are typed and destructured once:
    <script lang="ts">
      import type { Snippet } from 'svelte';
      interface Props { title: string; count?: number; children?: Snippet; onselect?: (id: string) => void; }
      let { title, count = 0, children, onselect }: Props = $props();
    </script>
    
    Rest props: let { class: klass, ...rest }: Props = $props().
  • Two-way binding is explicit: writable props use let { value = $bindable('') } = $props(). Most props are one-way — don't $bindable by default.
  • Events are callback props (onselect, onsubmit), not createEventDispatcher. DOM events are lowercase attributes: onclick, not on:click.
  • Snippets replace slots: define {#snippet name(args)}…{/snippet}, render {@render name(args)}. Default children arrive via the children snippet prop: {@render children?.()}. Type snippet params as Snippet<[Arg]>.
  • Shared reactive logic in .svelte.ts, exposed via getters (you cannot export a reassignable $state binding):
    export function createCounter() {
      let count = $state(0);
      return { get count() { return count; }, inc: () => count++ };
    }
    

SvelteKit routing and data

  • Load data in load, never in onMount. onMount/$effect fetching for initial data breaks SSR, causes waterfalls, and flashes empty UI.
  • Choose the load type deliberately: +page.server.ts when you touch DB, secrets, private env, or cookies (server-only; output must be serializable via devalue — Date/Map/Set/BigInt ok, class instances/functions no). +page.ts when data is public and should also run on client nav.
  • Use the load fetch, not the global — it forwards cookies, resolves relative URLs, dedupes and inlines responses into SSR:
    import type { PageServerLoad } from './$types';
    export const load: PageServerLoad = async ({ fetch, params, setHeaders, depends }) => {
      depends('app:items');
      const res = await fetch(`/api/items/${params.id}`);
      setHeaders({ 'cache-control': 'public, max-age=60' });
      return { item: await res.json() };
    };
    
  • Consume data via $props: let { data }: PageProps = $props(); — never re-fetch what load already returned. await parent() to compose parent data.
  • Stream slow data by returning a top-level promise (do not await it) and rendering with {#await}; keep critical data awaited. Only top-level keys stream.
  • Revalidation is explicit: tag a load with depends('app:items'), refresh with invalidate('app:items') (or invalidate(url) / invalidateAll()) from $app/navigation. Never location.reload().
  • Errors/redirects: error(404, 'Not found') and redirect(303, '/login') from @sveltejs/kit — called, not thrown. Render errors in +error.svelte via page.error; type the payload with App.Error.
  • Reactive page context from $app/state: import { page, navigating } from '$app/state'; read page.url, page.params, page.data, navigating.to directly (no $ prefix).
  • Shallow routing for modals/previews: pushState/replaceState from $app/navigation with typed App.PageState, read back via page.state.

Forms and mutations

  • Use form actions with progressive enhancement, not client fetch POST handlers:
    // +page.server.ts
    import { fail, redirect } from '@sveltejs/kit';
    import type { Actions } from './$types';
    export const actions: Actions = {
      create: async ({ request, locals }) => {
        if (!locals.user) redirect(303, '/login');
        const parsed = schema.safeParse(Object.fromEntries(await request.formData()));
        if (!parsed.success) return fail(400, { errors: z.flattenError(parsed.error) });
        await db.item.create({ data: { ...parsed.data, userId: locals.user.id } });
        redirect(303, '/items');
      }
    };
    
    <script lang="ts">
      import { enhance } from '$app/forms';
      import type { PageProps } from './$types';
      let { form }: PageProps = $props();
    </script>
    <form method="POST" action="?/create" use:enhance>
      <input name="name" />
      {#if form?.errors}<p>{form.errors.formErrors[0]}</p>{/if}
    </form>
    
  • The form works without JS (real method="POST"). use:enhance upgrades it and by default updates form, applies redirects, and invalidates all — only pass a callback for optimistic UI / manual applyAction. Validate on the server unconditionally; client validation is UX only.
  • Named actions via action="?/create" + button formaction. Non-form clients / webhooks / file responses use +server.ts (json(...) / Response) — not the default path for your own UI.
  • Remote functions (.remote.ts: query/form/command/prerender via $app/server) are experimental behind kit.experimental.remoteFunctions. Use only if the team opts in; the stable default stays load + actions.

Hooks and env

  • Auth and request context in src/hooks.server.ts: resolve the session, populate event.locals, then resolve:
    import type { Handle } from '@sveltejs/kit';
    export const handle: Handle = async ({ event, resolve }) => {
      event.locals.user = await getUser(event.cookies.get('session'));
      return resolve(event);
    };
    
    Compose hooks with sequence from @sveltejs/kit/hooks. handleFetch rewrites outgoing URLs; handleError logs and returns a client-safe error shape.
  • Never keep mutable module-level state on the server — it leaks across requests/users. Per-request state goes in event.locals; per-render shared state uses setContext/getContext in the component tree.
  • Private env only server-side: import { DATABASE_URL } from '$env/static/private' is allowed in *.server.ts/hooks/server-load only; importing it in a component fails the build. Public runtime vars need the PUBLIC_ prefix from $env/static/public (or $env/dynamic/public).
  • Prefer $env/static/* (dead-code-eliminated, validated at build) over $env/dynamic/*; use dynamic only when the value truly varies per deployment at runtime.

Testing

  • Component tests with vitest-browser-svelte (real browser via Playwright provider) — auto-retrying locators, correct runes/effects, no tick()/flushSync() gymnastics. Not jsdom:
    import { render } from 'vitest-browser-svelte';
    import { expect, test } from 'vitest';
    import Counter from './Counter.svelte';
    test('increments', async () => {
      const screen = render(Counter, { start: 0 });
      await screen.getByRole('button').click();
      await expect.element(screen.getByRole('status')).toHaveTextContent('1');
    });
    
  • Split Vitest into projects in vitest.config.ts: a browser project for *.svelte.test.ts, a Node project for server logic (*.test.ts). Keep legacy @testing-library/svelte/jsdom only for existing suites.
  • Rune logic in .svelte.ts: test in *.svelte.test.ts, wrap effectful reads in $effect.root(() => {…}) and call flushSync() to force synchronous updates.
  • Load functions / actions are plain functions — call with a mocked RequestEvent (mock cookies, fetch, locals) and assert the returned data or the fail/redirect/error thrown.
  • e2e with Playwright against vite preview / built adapter output: cover auth redirects, form submit + redirect, the no-JS fallback, and invalidation. Set webServer in playwright.config.ts. Assert via roles/text, not internal state.

Security

  • Secrets never reach the client: never import $env/*/private, DB clients, or API keys into universal (+page.ts) or component code — universal load runs in the browser. Keep them under $lib/server/.
  • Validate and coerce every boundary — formData, JSON bodies, url.searchParams, params, cookies — with Zod/Valibot before use. Never trust prior load data for authorization in an action; re-authorize against locals.
  • SvelteKit checks Origin on form POSTs (CSRF) by default — do not disable csrf.checkOrigin without a specific reason.
  • Escaping is automatic in {expr}. {@html value} is XSS unless value is server-sanitized (DOMPurify); never {@html} raw user input. Add a CSP via kit.csp.directives in svelte.config.js (SvelteKit auto-nonces its own scripts).
  • Set auth cookies with event.cookies.set(name, val, { httpOnly: true, secure: !dev, sameSite: 'lax', path: '/' }) (import { dev } from '$app/environment'). A flat secure: true breaks local dev — the browser won't send it over http://localhost — so gate on env; prod stays secure. Session tokens are httpOnly; never localStorage.
  • Authorize in every protected server load/action and in hooks.server, not just a layout or the UI — child server loads and actions are directly reachable. Return error(404) instead of 403 where you must not leak existence.
  • Pin the adapter and Node version; run pnpm audit. Never weaken TS strict or the CSRF check to make something pass.

Do

  • Type every load/action/handler from ./$types (PageProps, Actions, RequestHandler).
  • Derive with $derived; reach for $effect only for genuine side effects with teardown.
  • Return typed, serializable data from load; consume via data from $props().
  • Use use:enhance on real <form method="POST"> actions; validate server-side with a schema.
  • Read reactive app data from $app/state; revalidate with depends + invalidate.
  • Put shared reactive state in .svelte.ts (getters) or per-request setContext.
  • Compose {#snippet}/{@render} and callback props instead of slots/dispatchers.

Avoid

  • export let / $: derived / $: {…} — use $props() / $derived / $effect.
  • <slot> / <slot name> — use children and named snippet props + {@render}.
  • createEventDispatcher and on:event — use callback props and onevent attributes.
  • import { page } from '$app/stores' and $page — use $app/state's page.
  • Fetching initial data in onMount/$effect — use load.
  • throw error() / throw redirect() (SvelteKit-1 style) — call them directly.
  • $effect that writes derived state; mutable module-level state on the server; adapter-auto in production; secrets/private env in client-reachable modules; non-serializable values from load.

When you code

  • Ship small, reviewable diffs scoped to one route/component/feature; don't restructure folders unprompted.
  • After changes run sv check (types), eslint ., and the relevant Vitest/Playwright tests; report results. A change that fails sv check is not done.
  • Match the repo's existing adapter, auth approach, styling, and validation library; don't add a dependency without asking (npx sv add for integrations).
  • Ask before: changing the adapter, adding a global store/state library, touching hooks.server.ts auth, altering app.d.ts contracts, enabling experimental flags (remoteFunctions), or changing the CSP.
  • When a request implies a Svelte 4 pattern, implement the Svelte 5 runes equivalent and note the substitution in one line. Never commit secrets or weaken strict.

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Svelte 5 · SvelteKit 2 · TypeScript 6 · Vite 8.

Back to top ↑