SvelteKit skill for Codex

Server loads, real forms, the right adapter. Codex places each piece of SvelteKit code in the right route file, loads data in parallel with streaming for slow parts, and writes form actions that validate input, check ownership and keep working with JavaScript off. Secrets stay behind $env private modules and $lib/server, auth is enforced in every server load and action rather than one layout, and the adapter matches the real host.

  • Frontend
  • $sveltekit
  • 211 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- sveltekit

Installs to ~/.agents/skills/sveltekit/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $sveltekit, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds SvelteKit 2 apps - filesystem routes with +page, +layout and +server files, universal versus server load functions, form actions with use:enhance and fail, hooks for auth, $app/state, invalidation with depends, page options for prerender and ssr, environment modules, and choosing adapter-node, adapter-static, adapter-vercel or adapter-cloudflare. Use when adding routes, loading data, handling form submissions, protecting pages, fixing waterfalls or leaked secrets, or preparing a SvelteKit app for deployment.

Use it when

  • Adding routes, layouts, endpoints or load functions to a SvelteKit app
  • Handling form submissions with validation and progressive enhancement
  • Protecting pages and actions behind authentication
  • Choosing or changing a deployment adapter

Use something else when

  • The question is component reactivity, runes or Svelte 4 migration: use svelte-5-runes

How to check the result

svelte-check and the production build pass, every form works with JavaScript disabled, and no client file imports a private env module or $lib/server.

The mistake it prevents

Checking authentication only in a parent layout load, while child server loads and form actions run independently and stay open to direct requests.

~/.agents/skills/sveltekit/SKILL.md

--- name: sveltekit description: Builds SvelteKit 2 apps - filesystem routes with +page, +layout and +server files, universal versus server load functions, form actions with… license: MIT ---

Done well means: data loads in parallel on the server where it can, secrets never leave +page.server.ts / $lib/server, every mutation is a form action or +server.ts endpoint that validates input and checks auth, forms work without JavaScript and enhance with it, and the adapter matches where the app actually runs.

Inspect first

  1. Read AGENTS.md, package.json (@sveltejs/kit 2.x, svelte 5.x), and svelte.config.js (adapter, kit.alias, kit.experimental, prerender settings).
  2. Read src/hooks.server.ts for auth/session handling and src/app.d.ts for App.Locals, App.PageData, App.Error types.
  3. Map the route tree: find src/routes -name '+*' | sort.

Route files

FileRunsUse for
+page.svelteserver (SSR) + browserUI
+page.tsserver on first load, browser on navigationPublic API fetches, non-serializable data (components, classes)
+page.server.tsserver onlyDB, secrets, cookies, form actions
+layout(.server).tsas above, for all child routesShared data (user, nav)
+server.tsserver onlyJSON/REST endpoints, webhooks, files (GET, POST exports)
+error.svelteError UI for the subtree
(group)/Layout grouping without a URL segment
[slug], [[optional]], [...rest], [id=integer]Params, with matchers in src/params/

Load functions

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

export const load: PageServerLoad = async ({ params, locals, depends }) => {
  if (!locals.user) error(401, "Sign in required");
  depends("app:project");

  const project = await db.project.find(params.id, locals.user.id);
  if (!project) error(404, "Project not found");

  return {
    project,                                         // awaited: needed for first paint
    activity: db.activity.recent(project.id),        // not awaited: streamed to the page
  };
};
<!-- +page.svelte -->
<script lang="ts">
  let { data } = $props();
</script>

<h1>{data.project.name}</h1>
{#await data.activity}
  <p>Loading activity...</p>
{:then items}
  {#each items as a (a.id)}<p>{a.text}</p>{/each}
{:catch}
  <p>Could not load activity.</p>
{/await}

Rules:

  • In v2, error() and redirect() throw by themselves; do not write throw error(...) in new code, and never call them inside a try block that swallows them.
  • Avoid waterfalls: start independent queries together (Promise.all) and do not await parent() unless you need its data; when you do, call it after starting your own fetches.
  • Use the fetch passed to load, not the global one: it forwards cookies, works with relative URLs on the server, and inlines responses into the HTML so the browser does not refetch.
  • Returned data from +page.server.ts must be serializable by devalue (no functions or class instances). Dates, Maps, Sets and BigInt are fine.
  • Layout loads rerun when their dependencies change; a layout that reads url reruns on every navigation. Read only what you need.

Form actions

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

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

export const actions: Actions = {
  rename: async ({ request, params, locals }) => {
    if (!locals.user) error(401);
    const raw = Object.fromEntries(await request.formData());
    const parsed = Rename.safeParse(raw);
    if (!parsed.success) {
      return fail(400, { name: String(raw.name ?? ""), errors: z.flattenError(parsed.error).fieldErrors });
    }
    await db.project.rename(params.id, locals.user.id, parsed.data.name);
    return { saved: true };
  },
  delete: async ({ params, locals }) => {
    if (!locals.user) error(401);
    await db.project.delete(params.id, locals.user.id);
    redirect(303, "/projects");
  },
};
<script lang="ts">
  import { enhance } from "$app/forms";
  let { data, form } = $props();
</script>

<form method="POST" action="?/rename" use:enhance>
  <input name="name" value={form?.name ?? data.project.name} aria-invalid={!!form?.errors?.name} />
  {#if form?.errors?.name}<p role="alert">{form.errors.name[0]}</p>{/if}
  <button>Save</button>
</form>
  • Without JS the form posts and the page re-renders with form; with use:enhance it updates without reload and reruns load functions automatically.
  • Return user input in fail() so fields keep their value; never return passwords.
  • Customize enhance with a callback (use:enhance={() => async ({ update }) => { await update({ reset: false }) }}) for optimistic UI or to keep input.
  • Actions are POST endpoints anyone can call. Check locals.user and ownership in each one.
  • SvelteKit rejects cross-origin form posts by default (CSRF check via Origin); do not disable csrf.checkOrigin to fix a proxy problem, set ORIGIN / trusted origins instead.

Remote functions (query, form, command in .remote.ts files) are experimental behind kit.experimental.remoteFunctions. Use them only if the project already opted in.

Hooks and auth

// src/hooks.server.ts
import type { Handle } from "@sveltejs/kit";
export const handle: Handle = async ({ event, resolve }) => {
  event.locals.user = await getUserFromSession(event.cookies.get("session"));
  return resolve(event);
};

Protect data in each server load and action, not only in a layout: a +page.server.ts load runs in parallel with its parent layout and does not wait for the layout’s check unless it calls await parent().

Client state and navigation

  • import { page, navigating } from "$app/state" (Kit 2.12+, runes-based) replaces $app/stores.
  • invalidate("app:project") reruns loads that called depends("app:project"); invalidateAll() reruns everything. Prefer the targeted form.
  • goto(url, { replaceState: true, noScroll: true }) for filter changes; keep filters in url.searchParams so they survive reload and sharing.

Environment and page options

ModuleContainsUsable in
$env/static/privatebuild-time secretsserver files only
$env/dynamic/privateruntime secretsserver files only
$env/static/public / dynamic/publicPUBLIC_* varsanywhere

Importing a private module or $lib/server/* from client code fails the build; that is the guard, do not work around it.

Page options exported from +page.ts/+layout.ts: prerender = true | 'auto', ssr = false (SPA section), csr = false (no JS), trailingSlash. Prerendered pages cannot use form actions or per-request data.

Adapters

TargetAdapter
Node server, Docker@sveltejs/adapter-node (node build, set ORIGIN, PORT)
Fully static site or SPA@sveltejs/adapter-static (all routes prerendered, or fallback: '200.html')
Vercel@sveltejs/adapter-vercel
Netlify@sveltejs/adapter-netlify
Cloudflare Workers or Pages@sveltejs/adapter-cloudflare
Unsure / prototypeadapter-auto (replace before production)

Switching adapters changes runtime APIs (Node built-ins, file system, request size limits). Deploying is outward-facing: build and preview locally, then ask before running any deploy command.

Verification

npx svelte-kit sync && npx svelte-check --threshold warning
npm run build && npm run preview
npx playwright test          # if configured; test forms with JS disabled too
  • Disable JavaScript and submit each form: it must still work.
  • Network tab: no duplicate fetch after hydration for data loaded through load’s fetch.
  • grep -rn "\$env/static/private\|\$lib/server" src/routes --include=*.svelte returns nothing.

Output

List routes added or changed with their load and action files, auth checks per action, page options, env vars required, and the adapter plus any deploy settings (ORIGIN, body size limits).

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "SvelteKit"
  short_description: "Server loads, real forms, the right adapter"
  default_prompt: "$sveltekit Add a project detail page with a server load and a rename form action"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem