Frontend No. 067

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.

  • namesveltekit-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.

~/.claude/skills/sveltekit-apps/SKILL.md

--- name: sveltekit-apps description: Builds SvelteKit 2 apps with Svelte 5 runes ($state, $derived, $effect, $props, $bindable), snippets, callback-prop events, universal vs.… license: MIT ---

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 4Svelte 5
let count = 0 (reactive by assignment)let count = $state(0)
$: doubled = count * 2const doubled = $derived(count * 2)
$: { complex(); } side effect$effect(() => { ... return cleanup })
export let titlelet { title } = $props()
export let value + bind:valuelet { value = $bindable() } = $props()
<slot />, named slots{@render children?.()}, snippet props
on:click={fn}onclick={fn}
createEventDispatcherCallback 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 $derived for anything computable. $effect is for syncing with the outside world (DOM APIs, analytics, third-party widgets). Setting state inside $effect that it also reads causes loops; derive instead.
  • Effects run only in the browser, after mount. Return a cleanup for listeners and timers. Use $effect.pre only when you must run before DOM updates (e.g. scroll position preservation).
  • $derived.by(() => { ... }) for multi-statement derivations.
  • $state objects/arrays are deeply reactive proxies. Use $state.raw for large data you replace wholesale, and $state.snapshot(x) before passing state to structuredClone, postMessage, or libraries that dislike proxies.
  • Shared state in .svelte.ts: you cannot export a reassigned $state binding. 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 $state on the server is shared across all requests. Never put per-user data there in SSR; use event.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

FileRunsUse for
+page.server.ts / +layout.server.tsServer onlyDB, secrets, cookies, private APIs; return serializable data
+page.ts / +layout.tsServer on first load, then browser on navigationPublic APIs, returning non-serializable values (components, classes)
+server.tsServerJSON/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 calls await parent(), which creates a waterfall — call it last.
  • Re-run loads with invalidate("app:project") (matches depends) or invalidateAll().
  • Read the current page with import { page } from "$app/state" (Kit 2.12+), not the $app/stores store.

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:enhance upgrades it to fetch, updates form, 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, command in .remote.ts) are experimental behind kit.experimental.remoteFunctions; use them only if the project already opted in.

Hooks and auth

  • src/hooks.server.ts handle resolves the session once per request and sets event.locals.user; type Locals in src/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 in handle for whole areas plus in each load/action for record-level access.
  • handleFetch rewrites or adds credentials to server-side fetches; handleError logs 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/public for PUBLIC_*. 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

TargetAdapterNotes
Unknown / zero-config platforms@sveltejs/adapter-autoFine for prototypes; pin a specific adapter for production
Node server, Docker@sveltejs/adapter-nodeSet ORIGIN (or PROTOCOL_HEADER/HOST_HEADER) or form actions fail CSRF checks
Fully static / SPA@sveltejs/adapter-staticEvery route must prerender, or set fallback: "200.html" for SPA mode
Vercel@sveltejs/adapter-vercelPer-route config for runtime/regions/ISR
Netlify@sveltejs/adapter-netlifyEdge functions optional
Cloudflare@sveltejs/adapter-cloudflareWorkers/Pages; Node APIs limited; access bindings via platform.env

Verification

  1. npx sv check reports zero errors; a11y warnings reviewed.
  2. 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.
  3. Disable JavaScript and submit each form; it must still work and show validation errors.
  4. Test actions and +server.ts endpoints 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.

esc