Framework · Svelte 5 · SvelteKit 2 · TypeScript 6 · Vite 8
SvelteKit
Runes, load functions and form actions — the Svelte 5 way.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou 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. Noexport let, no$:, no<slot>, nocreateEventDispatcher. - SvelteKit 2.69 (
@sveltejs/kit) — file routing, load functions, form actions,hooks.server.error/redirectare called, not thrown:redirect(303, '/x'). - Vite 8 (Rolldown, Rust bundler) — dev/build/preview. TypeScript 6.0 with
strict: true,verbatimModuleSyntax(useimport type),"moduleResolution": "bundler". (TS 7 Go compiler is imminent — keep types clean.) - CLI:
sv—npx sv create,npx sv add tailwindcss,npx sv check.create-svelteis 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-autoonly for throwaway demos. - Lint/format: ESLint 10 flat config (
eslint.config.js; legacy.eslintrc/ESLINT_USE_FLAT_CONFIGremoved 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)— theerr.flatten()/err.format()instance methods are deprecated.
Project conventions
- Routes in
src/routes; shared code insrc/lib($libalias). Server-only code insrc/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]insrc/params. - Components
PascalCase.svelte. Reactive logic outside components lives in.svelte.ts/.svelte.jsfiles 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-svelteis the only formatter.tsconfig.jsonmustextends: "./.svelte-kit/tsconfig.json".
Runes and components
- State:
let count = $state(0).$statedeep-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:
Anlet doubled = $derived(count * 2); let total = $derived.by(() => items.reduce((s, i) => s + i.price, 0));$effectthat assigns to another$stateto "sync" values is the classic Svelte 5 anti-pattern — use$derived. Never write to a$derived. $effectis 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.preruns before DOM update.- Props are typed and destructured once:
Rest props:<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>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$bindableby default. - Events are callback props (
onselect,onsubmit), notcreateEventDispatcher. DOM events are lowercase attributes:onclick, noton:click. - Snippets replace slots: define
{#snippet name(args)}…{/snippet}, render{@render name(args)}. Default children arrive via thechildrensnippet prop:{@render children?.()}. Type snippet params asSnippet<[Arg]>. - Shared reactive logic in
.svelte.ts, exposed via getters (you cannot export a reassignable$statebinding):export function createCounter() { let count = $state(0); return { get count() { return count; }, inc: () => count++ }; }
SvelteKit routing and data
- Load data in
load, never inonMount.onMount/$effectfetching for initial data breaks SSR, causes waterfalls, and flashes empty UI. - Choose the load type deliberately:
+page.server.tswhen you touch DB, secrets, private env, orcookies(server-only; output must be serializable via devalue — Date/Map/Set/BigInt ok, class instances/functions no).+page.tswhen 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 whatloadalready returned.await parent()to compose parent data. - Stream slow data by returning a top-level promise (do not
awaitit) and rendering with{#await}; keep critical data awaited. Only top-level keys stream. - Revalidation is explicit: tag a load with
depends('app:items'), refresh withinvalidate('app:items')(orinvalidate(url)/invalidateAll()) from$app/navigation. Neverlocation.reload(). - Errors/redirects:
error(404, 'Not found')andredirect(303, '/login')from@sveltejs/kit— called, not thrown. Render errors in+error.svelteviapage.error; type the payload withApp.Error. - Reactive page context from
$app/state:import { page, navigating } from '$app/state';readpage.url,page.params,page.data,navigating.todirectly (no$prefix). - Shallow routing for modals/previews:
pushState/replaceStatefrom$app/navigationwith typedApp.PageState, read back viapage.state.
Forms and mutations
- Use form actions with progressive enhancement, not client
fetchPOST 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:enhanceupgrades it and by default updatesform, applies redirects, and invalidates all — only pass a callback for optimistic UI / manualapplyAction. 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/prerendervia$app/server) are experimental behindkit.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, populateevent.locals, thenresolve:
Compose hooks withimport type { Handle } from '@sveltejs/kit'; export const handle: Handle = async ({ event, resolve }) => { event.locals.user = await getUser(event.cookies.get('session')); return resolve(event); };sequencefrom@sveltejs/kit/hooks.handleFetchrewrites outgoing URLs;handleErrorlogs 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 usessetContext/getContextin 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 thePUBLIC_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, notick()/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 callflushSync()to force synchronous updates. - Load functions / actions are plain functions — call with a mocked
RequestEvent(mockcookies,fetch,locals) and assert the returned data or thefail/redirect/errorthrown. - e2e with Playwright against
vite preview/ built adapter output: cover auth redirects, form submit + redirect, the no-JS fallback, and invalidation. SetwebServerinplaywright.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 priorloaddata for authorization in an action; re-authorize againstlocals. - SvelteKit checks Origin on form POSTs (CSRF) by default — do not disable
csrf.checkOriginwithout a specific reason. - Escaping is automatic in
{expr}.{@html value}is XSS unlessvalueis server-sanitized (DOMPurify); never{@html}raw user input. Add a CSP viakit.csp.directivesinsvelte.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 flatsecure: truebreaks local dev — the browser won't send it overhttp://localhost— so gate on env; prod stays secure. Session tokens arehttpOnly; neverlocalStorage. - Authorize in every protected server
load/action and inhooks.server, not just a layout or the UI — child server loads and actions are directly reachable. Returnerror(404)instead of 403 where you must not leak existence. - Pin the adapter and Node version; run
pnpm audit. Never weaken TSstrictor the CSRF check to make something pass.
Do
- Type every
load/action/handler from./$types(PageProps,Actions,RequestHandler). - Derive with
$derived; reach for$effectonly for genuine side effects with teardown. - Return typed, serializable data from
load; consume viadatafrom$props(). - Use
use:enhanceon real<form method="POST">actions; validate server-side with a schema. - Read reactive app data from
$app/state; revalidate withdepends+invalidate. - Put shared reactive state in
.svelte.ts(getters) or per-requestsetContext. - Compose
{#snippet}/{@render}and callback props instead of slots/dispatchers.
Avoid
export let/$:derived /$: {…}— use$props()/$derived/$effect.<slot>/<slot name>— usechildrenand named snippet props +{@render}.createEventDispatcherandon:event— use callback props andoneventattributes.import { page } from '$app/stores'and$page— use$app/state'spage.- Fetching initial data in
onMount/$effect— useload. throw error()/throw redirect()(SvelteKit-1 style) — call them directly.$effectthat writes derived state; mutable module-level state on the server;adapter-autoin production; secrets/private env in client-reachable modules; non-serializable values fromload.
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 failssv checkis not done. - Match the repo's existing adapter, auth approach, styling, and validation library; don't add a dependency without asking (
npx sv addfor integrations). - Ask before: changing the adapter, adding a global store/state library, touching
hooks.server.tsauth, alteringapp.d.tscontracts, 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.