SvelteKit Apps skill for Claude Code
Runes, load functions and forms that work without JS. Claude writes Svelte 5 with runes, snippets and callback props instead of extending legacy export let, $: and on:click code. It picks server or universal load files deliberately, streams slow data, builds form actions that degrade without JavaScript, gates auth in hooks plus each action, and chooses the adapter that matches where the app will actually run.
- name
sveltekit-apps - size207 lines · ≈2,150 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds SvelteKit 2 apps with Svelte 5 runes ($state, $derived, $effect, $props, $bindable), snippets, callback-prop events, universal vs. server load functions, form actions with use:enhance, hooks, invalidation, and adapter selection for Node, static, Vercel, Netlify, or Cloudflare. Use when writing .svelte or .svelte.ts files, migrating from Svelte 4 stores and on:event syntax, adding +page.server.ts loads or actions, or choosing a deployment adapter. Try asking
- Migrate this Svelte 4 component with stores and $: blocks to runes
- Add a settings page with a form action that validates and keeps input on error
- We are moving off Vercel to a Docker host — switch the adapter and fix what breaks
Use it when
- Writing .svelte or .svelte.ts files in a Svelte 5 project
- Adding load functions, form actions or +server.ts endpoints
- Migrating Svelte 4 syntax to runes
- Choosing or changing a SvelteKit adapter
Skip it when
- The site is mostly static content with few interactive parts — use astro-sites
- The project uses Vue or Nuxt — use vue-composition
How to check the result
sv check passes, each changed route renders identically on client navigation and hard reload in preview, and every form still submits with JavaScript disabled.
The common mistake it prevents
Putting per-user $state in a shared .svelte.ts module, which on the server leaks one user's data into another user's render.
A good SvelteKit app loads data in load functions on the right side of the network,
mutates through form actions that work without JavaScript, uses runes for local state,
and treats Svelte 4 syntax (export let, $:, on:click, stores for local state)
as legacy to migrate, not to extend.
Before changing anything
npm ls svelte @sveltejs/kit @sveltejs/vite-plugin-svelte # Svelte 5 + Kit 2 expected
npx sv check # svelte-check: types + a11y warnings
grep -rn "export let\|\$:\|on:click\|createEventDispatcher" src --include=*.svelte | head
If legacy syntax dominates, migrate per component with npx sv migrate svelte-5,
then review its output by hand — it converts mechanically and leaves run() shims
where $: had side effects.
Runes cheat sheet
| Svelte 4 | Svelte 5 |
|---|---|
let count = 0 (reactive by assignment) | let count = $state(0) |
$: doubled = count * 2 | const doubled = $derived(count * 2) |
$: { complex(); } side effect | $effect(() => { ... return cleanup }) |
export let title | let { title } = $props() |
export let value + bind:value | let { value = $bindable() } = $props() |
<slot />, named slots | {@render children?.()}, snippet props |
on:click={fn} | onclick={fn} |
createEventDispatcher | Callback props: let { onselect } = $props() |
| Writable store for shared state | $state in a .svelte.ts module |
<script lang="ts">
import type { Snippet } from "svelte";
let { items, row, onselect }: {
items: Item[];
row: Snippet<[Item]>;
onselect?: (id: string) => void;
} = $props();
let query = $state("");
const visible = $derived(items.filter((i) => i.name.toLowerCase().includes(query.toLowerCase())));
</script>
<input bind:value={query} type="search" aria-label="Filter" />
<ul>
{#each visible as item (item.id)}
<li><button type="button" onclick={() => onselect?.(item.id)}>{@render row(item)}</button></li>
{/each}
</ul>
Rune rules and traps
- Use
$derivedfor anything computable.$effectis for syncing with the outside world (DOM APIs, analytics, third-party widgets). Setting state inside$effectthat it also reads causes loops; derive instead. - Effects run only in the browser, after mount. Return a cleanup for listeners and
timers. Use
$effect.preonly when you must run before DOM updates (e.g. scroll position preservation). $derived.by(() => { ... })for multi-statement derivations.$stateobjects/arrays are deeply reactive proxies. Use$state.rawfor large data you replace wholesale, and$state.snapshot(x)before passing state tostructuredClone,postMessage, or libraries that dislike proxies.- Shared state in
.svelte.ts: you cannot export a reassigned$statebinding. Export an object and mutate its properties, or export getter functions.
// src/lib/cart.svelte.ts
export const cart = $state({ lines: [] as CartLine[] });
export function addLine(line: CartLine) { cart.lines.push(line); }
- Module-level
$stateon the server is shared across all requests. Never put per-user data there in SSR; useevent.locals, load data, or context (setContext). - Keyed
{#each items as item (item.id)}for any list that reorders or edits.
Load functions: pick the file
| File | Runs | Use for |
|---|---|---|
+page.server.ts / +layout.server.ts | Server only | DB, secrets, cookies, private APIs; return serializable data |
+page.ts / +layout.ts | Server on first load, then browser on navigation | Public APIs, returning non-serializable values (components, classes) |
+server.ts | Server | JSON/REST endpoints, webhooks, file responses |
// src/routes/projects/[id]/+page.server.ts
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params, locals, depends }) => {
if (!locals.user) error(401, "Sign in required"); // Kit 2: call, do not throw
depends("app:project");
const project = await db.project.find(params.id, locals.user.id);
if (!project) error(404, "Project not found");
return {
project, // awaited: blocks render
activity: db.activity.list(params.id), // unawaited promise: streams
};
};
<!-- +page.svelte -->
<script lang="ts">
import type { PageProps } from "./$types";
let { data }: PageProps = $props();
</script>
<h1>{data.project.name}</h1>
{#await data.activity}<p>Loading activity…</p>{:then items}<Activity {items} />{/await}
- Use the generated
./$types(PageServerLoad,PageProps,Actions); never hand-type. - Parallelize independent awaits with
Promise.all; layouts and pages load in parallel unless a child callsawait parent(), which creates a waterfall — call it last. - Re-run loads with
invalidate("app:project")(matchesdepends) orinvalidateAll(). - Read the current page with
import { page } from "$app/state"(Kit 2.12+), not the$app/storesstore.
Form actions
// +page.server.ts
import { fail, redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
export const actions: Actions = {
rename: async ({ request, locals, params }) => {
const form = await request.formData();
const name = String(form.get("name") ?? "").trim();
if (name.length < 2) return fail(400, { name, error: "Name is too short" });
await db.project.rename(params.id, locals.user.id, name);
redirect(303, `/projects/${params.id}`);
},
};
<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { data, form }: PageProps = $props();
</script>
<form method="POST" action="?/rename" use:enhance>
<input name="name" value={form?.name ?? data.project.name} aria-invalid={!!form?.error} />
{#if form?.error}<p role="alert">{form.error}</p>{/if}
<button>Save</button>
</form>
- The form works without JavaScript;
use:enhanceupgrades it to fetch, updatesform, and invalidates loads. - Return
fail(status, data)for validation errors so the page re-renders with input preserved. Use 303 for redirect after POST. - Authorize inside each action; actions are POST endpoints anyone can call.
- Remote functions (
query,form,commandin.remote.ts) are experimental behindkit.experimental.remoteFunctions; use them only if the project already opted in.
Hooks and auth
src/hooks.server.tshandleresolves the session once per request and setsevent.locals.user; typeLocalsinsrc/app.d.ts.- Do not rely on a layout load for auth gating — layouts may not re-run, and
+server.ts/actions bypass them. Check inhandlefor whole areas plus in each load/action for record-level access. handleFetchrewrites or adds credentials to server-side fetches;handleErrorlogs unexpected errors and returns a safe message.
Environment and page options
$env/static/private(inlined at build, server only),$env/dynamic/private(read at runtime),$env/static/public/$env/dynamic/publicforPUBLIC_*. Importing a private module into client code fails the build — keep it that way.- Page options in
+page.ts/+layout.ts:export const prerender = true,export const ssr = false(SPA-only route),export const csr = false(no JS).
Adapter choice
| Target | Adapter | Notes |
|---|---|---|
| Unknown / zero-config platforms | @sveltejs/adapter-auto | Fine for prototypes; pin a specific adapter for production |
| Node server, Docker | @sveltejs/adapter-node | Set ORIGIN (or PROTOCOL_HEADER/HOST_HEADER) or form actions fail CSRF checks |
| Fully static / SPA | @sveltejs/adapter-static | Every route must prerender, or set fallback: "200.html" for SPA mode |
| Vercel | @sveltejs/adapter-vercel | Per-route config for runtime/regions/ISR |
| Netlify | @sveltejs/adapter-netlify | Edge functions optional |
| Cloudflare | @sveltejs/adapter-cloudflare | Workers/Pages; Node APIs limited; access bindings via platform.env |
Verification
npx sv checkreports zero errors; a11y warnings reviewed.npm run build && npm run preview: navigate client-side and do a hard reload on each changed route — both paths must render the same data.- Disable JavaScript and submit each form; it must still work and show validation errors.
- Test actions and
+server.tsendpoints directly (curl or Playwright request) with a missing or wrong user to confirm authorization.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.