← All posts
·17 min read

Claude Code with SvelteKit: Routing, Load Functions, Form Actions, and Hooks

Claude CodeSvelteKitSvelteWorkflow
Claude Code with SvelteKit: Routing, Load Functions, Form Actions, and Hooks

Why SvelteKit needs its own CLAUDE.md (separate from a Svelte config)

A Svelte CLAUDE.md and a SvelteKit CLAUDE.md are not the same document. Svelte is the component model. SvelteKit is the fullstack framework built on top of it. The framework introduces a distinct set of concepts that Claude Code must navigate correctly for the output to be usable: file-based routing with specific filename conventions, a server/client load function split, form actions as the mutation primitive, hooks for middleware, adapters for deployment targets, and two different $env module namespaces with hard security boundaries.

Without a SvelteKit-specific CLAUDE.md, Claude will generate code that compiles but violates the framework's mental model in ways that are tedious to trace. It will put database calls in +page.ts instead of +page.server.ts. It will create API routes for mutations that should be form actions. It will reach for fetch in onMount instead of using the load function. It will import $env/dynamic/private on the client. None of these fail with a helpful error message on the first run. They degrade in production, during SSR, or under a hard page reload.

The Claude Code with Svelte guide covers the component layer: Svelte 5 runes, props, events, shared state, and testing. This guide covers the SvelteKit server layer. Both CLAUDE.md files belong in a fullstack SvelteKit project. If you are starting from scratch with Claude Code, the Claude Code setup guide covers installation first.

The SvelteKit CLAUDE.md template

The CLAUDE.md at the project root is read at the start of every Claude Code session. For a SvelteKit fullstack application, it needs to declare the routing file conventions, the load function split, the mutation model, the middleware setup, the deployment adapter, and the environment variable access rules.

# SvelteKit 2.x project rules

## Stack
- SvelteKit 2.x with Svelte 5 (runes mode)
- TypeScript 5.x strict mode throughout
- Vite 6.x bundler (vite.config.ts at root)
- Drizzle ORM with PostgreSQL (db client in src/lib/server/db.ts)
- Tailwind CSS 4.x for styling
- Adapter: @sveltejs/adapter-node (or adapter-vercel / adapter-cloudflare; see Adapter section)

## File-based routing conventions (src/routes/)
- +page.svelte          // page component (always required for a route)
- +page.ts             // universal load function (runs on server + client)
- +page.server.ts      // server-only load + form actions (database, private APIs, auth)
- +layout.svelte       // layout component, must include <slot /> (Svelte 5: {@render children()})
- +layout.ts           // universal layout load
- +layout.server.ts    // server-only layout load (auth session, user data for entire subtree)
- +error.svelte        // error page for this route segment
- +server.ts           // API route (only for endpoints consumed by external clients or non-SvelteKit apps)
- (group)/             // route group folder, no URL segment
- [...rest]/           // catch-all route

## Load function rules (CRITICAL)
- +page.server.ts: use for ALL database queries, private API calls, session reads, auth checks
- +page.ts: use ONLY for public API calls that must also run on the client during navigation
- NEVER put database imports or $env/dynamic/private imports in +page.ts
- Return value is typed automatically via PageServerLoad / PageLoad from ./$types
- Access parent layout data with: const parentData = await parent()
- Throw error(404, 'Not found') or error(403, 'Forbidden') for HTTP errors
- Throw redirect(302, '/login') for auth redirects
- NEVER return undefined from a load function, always return an object

## Form actions (mutations in +page.server.ts)
- All mutations use form actions, not API routes, for operations called from this app
- Default action: export const actions: Actions = { default: async ({ request }) => {} }
- Named actions: export const actions: Actions = { create: ..., update: ..., delete: ... }
- Return fail(422, { errors }) for validation failures (preserves form data)
- Return redirect(303, '/path') for successful mutations (Post/Redirect/Get)
- Parse FormData through Zod (never trust raw form values)
- Use use:enhance directive on all forms for progressive enhancement
- Access action result in component via: let { form } = $props<{ data: PageData; form: ActionData }>()

## hooks.server.ts (src/hooks.server.ts)
- handle: RequestHandler; intercepts every request, used for auth sessions, CORS, logging
- handleFetch: intercepts server-side fetch calls (add auth headers to internal API calls)
- handleError: catches unexpected errors, logs them, returns safe user-facing message
- Session pattern: read session cookie in handle, attach user to event.locals
- Define locals type in src/app.d.ts: interface Locals { user: User | null }

## Environment variables ($env modules, HARD RULES)
- $env/static/public: build-time public vars (VITE_ prefixed or PUBLIC_ prefixed in .env)
  → safe to import in +page.svelte, +page.ts, any client-side code
- $env/static/private: build-time private vars (DATABASE_URL, SECRET_KEY, etc.)
  → only import in +page.server.ts, +layout.server.ts, +server.ts, hooks.server.ts, src/lib/server/**
- $env/dynamic/public: runtime public vars
  → same client-safe rules as static/public
- $env/dynamic/private: runtime private vars (populated at request time by adapter)
  → same server-only rules as static/private
- NEVER import $env/static/private or $env/dynamic/private in +page.ts or +page.svelte
- NEVER use process.env directly; always use the $env modules

## Server-only modules (src/lib/server/)
- All files under src/lib/server/ are server-only (SvelteKit enforces this at build)
- Database client: src/lib/server/db.ts
- Auth utilities: src/lib/server/auth.ts
- Email, payment, third-party APIs: src/lib/server/{service}.ts
- Import these ONLY from server files (+page.server.ts, +layout.server.ts, hooks.server.ts, +server.ts)
- NEVER import src/lib/server/** from +page.ts or +page.svelte

## Adapter
- Development: adapter-node (node src/index.js to run)
- Vercel: swap to @sveltejs/adapter-vercel in svelte.config.js, no other changes needed
- Cloudflare Pages: swap to @sveltejs/adapter-cloudflare, use platform.env for env vars (not process.env)
- Do not mix adapter assumptions, if adapter is cloudflare, do not use process.env

## Hard rules
- NEVER use onMount for data fetching, all data comes through load functions
- NEVER create API routes (+server.ts) for mutations called only from this app
- NEVER import database clients outside src/lib/server/
- ALWAYS use +page.server.ts for any route that reads from the database
- ALWAYS use the ./$types import for PageServerLoad, PageLoad, Actions, ActionData, PageData
- ALWAYS use named exports for form actions (not a default actions object)
- ALWAYS call svelte-check after structural changes

The template is longer than a typical CLAUDE.md because SvelteKit's server/client boundary is more nuanced than most frameworks. Every line corresponds to a failure mode Claude produces in practice. The sections below explain the most important ones.

File-based routing: what Claude gets wrong

SvelteKit's routing convention is precise. The filename is not a suggestion; it determines where code runs. Claude Code misreads or conflates these files most often in two ways.

The first confusion is +page.ts versus +page.server.ts. Both export a load function. The difference: +page.ts runs on the server during SSR and then runs again on the client during client-side navigation. +page.server.ts runs only on the server, always. Database queries belong in +page.server.ts. Public fetch calls that the client also needs to re-execute during navigation belong in +page.ts. Claude will put database calls in +page.ts without the explicit rule because it sees load functions in both and treats them as equivalent.

The second confusion is the +server.ts file. Claude Code's Next.js training associates server files with API routes, and it will scaffold +server.ts files for every mutation endpoint by reflex. In SvelteKit, +server.ts is for external API endpoints, not for mutations called from within the application. Form actions in +page.server.ts replace API routes for internal mutations, with zero extra code on the frontend.

Here is the canonical +page.server.ts pattern that covers both load and actions in one file:

// src/routes/dashboard/projects/[id]/+page.server.ts
import type { PageServerLoad, Actions } from './$types';
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { db } from '$lib/server/db';

export const load: PageServerLoad = async ({ params, locals }) => {
  if (!locals.user) throw redirect(302, '/login');

  const project = await db.query.projects.findFirst({
    where: (p, { eq }) => eq(p.id, params.id),
  });

  if (!project) throw error(404, 'Project not found');
  if (project.userId !== locals.user.id) throw error(403, 'Forbidden');

  return { project };
};

const UpdateSchema = z.object({
  name: z.string().min(1).max(120),
  status: z.enum(['active', 'archived']),
});

export const actions: Actions = {
  update: async ({ request, params, locals }) => {
    if (!locals.user) throw redirect(302, '/login');

    const formData = await request.formData();
    const result = UpdateSchema.safeParse({
      name: formData.get('name'),
      status: formData.get('status'),
    });

    if (!result.success) {
      return fail(422, { errors: result.error.flatten().fieldErrors });
    }

    await db.update(projects)
      .set(result.data)
      .where(eq(projects.id, params.id));

    throw redirect(303, `/dashboard/projects/${params.id}`);
  },

  delete: async ({ params, locals }) => {
    if (!locals.user) throw redirect(302, '/login');

    await db.delete(projects).where(eq(projects.id, params.id));
    throw redirect(303, '/dashboard');
  },
};

The locals.user pattern comes from hooks.server.ts populating event.locals on every request. Without the hooks section in CLAUDE.md, Claude either skips the auth check entirely or reaches for a cookie read in each load function. With the locals pattern established, one auth check in the hook propagates to every route.

Accessing parent data and nested layouts

SvelteKit's nested layout system means a +layout.server.ts can load data that every child route in the subtree needs (the current user, for example). Child load functions access this data via await parent(). Without the pattern in CLAUDE.md, Claude will re-query the database in every child load function for data the parent already fetched.

Add a parent data section to CLAUDE.md:

## Accessing parent layout data

### Layout load (src/routes/dashboard/+layout.server.ts)
import type { LayoutServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';

export const load: LayoutServerLoad = async ({ locals }) => {
  if (!locals.user) throw redirect(302, '/login');
  return { user: locals.user };
};

### Child route reading parent data (+page.server.ts)
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ parent, params }) => {
  const { user } = await parent();
  // user is now typed from the layout load return
  const project = await db.query.projects.findFirst({
    where: (p, { eq }) => eq(p.id, params.id),
  });
  return { project, user };
};

### In the page component (+page.svelte)
<script lang="ts">
  import type { PageData } from './$types';
  let { data } = $props<{ data: PageData }>();
  // data.user comes from the layout, data.project from the page load
</script>

### Rules
- Call await parent() to inherit layout data, do not re-query for data the layout already loaded
- Parent data is merged with child data in PageData automatically
- Always await parent() at the top of the load function before your own async calls

The await parent() call also ensures the parent's load completes before the child runs, which is the correct execution order when layout data gates access to child data. Claude generates a parallel structure (two independent load functions with no relationship) when this pattern is missing, which can cause race conditions or redundant queries.

hooks.server.ts: the middleware layer

hooks.server.ts sits at the application boundary. Every HTTP request passes through its handle function before reaching any route. This is the correct place for session parsing, authentication, CORS headers, and request logging. Claude Code knows Express-style middleware well. It does not apply that knowledge to SvelteKit hooks without being told to.

Add hooks to CLAUDE.md:

## hooks.server.ts setup (src/hooks.server.ts)

### Type declarations first (src/app.d.ts)
declare global {
  namespace App {
    interface Locals {
      user: {
        id: string;
        email: string;
        role: 'admin' | 'member';
      } | null;
    }
  }
}
export {};

### Canonical handle hook
import type { Handle } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { sessions } from '$lib/server/db/schema';

export const handle: Handle = async ({ event, resolve }) => {
  const sessionId = event.cookies.get('session_id');

  if (sessionId) {
    const session = await db.query.sessions.findFirst({
      where: (s, { eq }) => eq(s.id, sessionId),
      with: { user: true },
    });

    if (session && session.expiresAt > new Date()) {
      event.locals.user = session.user;
    } else {
      event.locals.user = null;
    }
  } else {
    event.locals.user = null;
  }

  return resolve(event);
};

### handleError hook
import type { HandleServerError } from '@sveltejs/kit';

export const handleError: HandleServerError = async ({ error, event }) => {
  console.error('Unhandled error:', error, event.url.pathname);
  return {
    message: 'An unexpected error occurred.',
    code: 'INTERNAL_ERROR',
  };
};

### Rules
- The handle hook runs on every request including static assets, check event.url.pathname to skip unnecessary DB calls
- Set event.locals in handle, read event.locals in load functions and form actions
- handleError receives the raw error, never expose it to the client, return a sanitised message
- sequence() from @sveltejs/kit/hooks chains multiple handle functions cleanly

The event.locals pattern is what connects hooks to load functions. Without the app.d.ts type declaration, event.locals.user will be any, TypeScript will not catch access to undefined properties, and Claude will generate inconsistent access patterns across routes. With the declaration in place, Claude generates typed locals access throughout.

The handleError hook is worth including because without it, unhandled errors in load functions or actions expose raw stack traces in production. Claude generates error handling per-route when the application-level hook is not established, which produces inconsistent error surfaces.

$env modules and the security boundary

SvelteKit provides four environment variable modules. The split is along two axes: static (resolved at build time) versus dynamic (resolved at request time), and public (safe for client) versus private (server only). Claude Code will use the wrong module most often in two scenarios: importing $env/dynamic/private in a universal load function, or reaching for process.env directly instead of using the $env modules.

The CLAUDE.md rules already cover this, but a concrete example of what each module is for helps Claude apply them correctly. Add examples to CLAUDE.md:

## $env module examples

### $env/static/public (import in any file)
import { PUBLIC_APP_URL, PUBLIC_POSTHOG_KEY } from '$env/static/public';
// Values are inlined at build time. Declare in .env as PUBLIC_APP_URL=...

### $env/static/private (import ONLY in server files)
import { DATABASE_URL, SESSION_SECRET } from '$env/static/private';
// Values are inlined at build time. Never exposed to client bundle.
// Use in: +page.server.ts, +layout.server.ts, hooks.server.ts, src/lib/server/**

### $env/dynamic/public (import in any file)
import { env } from '$env/dynamic/public';
const apiUrl = env.PUBLIC_API_URL;
// Values available at runtime. Use when value may change without rebuild.

### $env/dynamic/private (import ONLY in server files, adapter-specific)
import { env } from '$env/dynamic/private';
const dbUrl = env.DATABASE_URL;
// Use on Cloudflare Pages (env vars are in platform.env, not process.env)
// On Node and Vercel, prefer $env/static/private for build-time vars

### Never
- Never use process.env in a SvelteKit project except in vite.config.ts or svelte.config.js
- Never import $env/static/private or $env/dynamic/private in +page.ts or +page.svelte

The Cloudflare nuance matters for projects that deploy to Cloudflare Pages or Workers. In that environment, process.env is not populated. Environment variables come from the platform's env object, which is what $env/dynamic/private exposes. Claude will generate process.env.DATABASE_URL for a Cloudflare project when the adapter context is not in CLAUDE.md, and the app will fail silently in production (the variable is simply undefined).

Form actions: the complete pattern

Form actions replace API routes for mutations within a SvelteKit application. The pattern requires two files to work correctly: the action definition in +page.server.ts and the form component in +page.svelte. Claude generates correct actions and then generates incorrect components that bypass them, or vice versa, when the full pattern is not in CLAUDE.md.

Add the complete form action pattern to CLAUDE.md:

## Complete form action pattern

### Named actions in +page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions: Actions = {
  create: async ({ request, locals }) => {
    const data = await request.formData();
    const result = CreateSchema.safeParse(Object.fromEntries(data));
    if (!result.success) {
      return fail(422, {
        errors: result.error.flatten().fieldErrors,
        values: Object.fromEntries(data), // preserve form values
      });
    }
    const record = await db.insert(table).values(result.data).returning();
    throw redirect(303, `/items/${record[0].id}`);
  },
};

### Progressive-enhanced form in +page.svelte
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { PageData, ActionData } from './$types';

  let { data, form } = $props<{ data: PageData; form: ActionData }>();
</script>

<form method="POST" action="?/create" use:enhance>
  <input name="name" value={form?.values?.name ?? ''} />
  {#if form?.errors?.name}
    <p class="error">{form.errors.name[0]}</p>
  {/if}
  <button type="submit">Create</button>
</form>

### Rules
- use:enhance is always present on forms that call actions
- action="?/actionName" for named actions, action="" (or omit) for default action
- fail() returns a 4xx response with data, the form re-renders with the data in the form prop
- redirect() throws, it is a redirect, not a return
- Preserve form values in fail() so the user does not lose their input on validation error
- $props<{ data: PageData; form: ActionData }>() is the correct type for a page with actions

The form?.values?.name ?? '' pattern for preserving form values on validation failure is what most developers miss first. When a form action returns fail(), the page re-renders with the form prop containing whatever you returned. If you do not include the original form values in the fail() payload, the user's input is gone. Claude will omit the values field in fail() when the pattern is not explicit.

The throw redirect(303, '/path') inside a form action is correct. 303 See Other is the correct status after a successful POST. SvelteKit uses redirect() as a throw (like Remix), meaning it short-circuits the rest of the action immediately. Using return redirect(303, ...) will cause a TypeScript error because redirect() is typed as never.

Failure modes Claude generates without context

Without a SvelteKit-specific CLAUDE.md, Claude produces predictable categories of wrong code. Add these to CLAUDE.md as an anti-patterns section:

## Anti-patterns (never generate these)

### onMount data fetching
// WRONG, data is already available from the load function
onMount(async () => {
  const res = await fetch('/api/projects');
  projects = await res.json();
});
// RIGHT: use load function + let { data } = $props()

### API route for internal mutation
// WRONG, creating +server.ts for every mutation endpoint
// src/routes/api/projects/+server.ts with export async function POST()
// RIGHT: form action in +page.server.ts, use:enhance on the form

### process.env access in SvelteKit
// WRONG
const dbUrl = process.env.DATABASE_URL;
// RIGHT
import { DATABASE_URL } from '$env/static/private'; // server files only

### Database import in +page.ts
// WRONG
import { db } from '$lib/server/db'; // in +page.ts, SvelteKit will throw at build
// RIGHT: move to +page.server.ts

### $env/static/private in a universal load
// WRONG, in +page.ts
import { SECRET_KEY } from '$env/static/private'; // will leak to client bundle
// RIGHT: move to +page.server.ts

### Missing use:enhance
// WRONG, form without progressive enhancement
<form method="POST" action="?/update">
// RIGHT
<form method="POST" action="?/update" use:enhance>

### Manual fetch inside an action when a redirect is needed
// WRONG
export const actions = {
  create: async ({ request }) => {
    await db.insert(table).values(data);
    return { success: true }; // stays on the POST URL, double-submit risk
  },
};
// RIGHT: throw redirect(303, '/success-path')

### Returning data instead of using locals for auth state
// WRONG, checking auth in every load function with a cookie read
const cookie = event.cookies.get('session');
const user = await validateCookie(cookie);
// RIGHT: read event.locals.user set by hooks.server.ts handle

The process.env anti-pattern is the one that surfaces most often in Cloudflare and Vercel deployments where the environment differs from local development. Claude will fall back to Node.js conventions when it does not have a clear signal that $env modules are the correct path.

The database import in +page.ts anti-pattern deserves special attention. SvelteKit's build tool detects server-only imports in universal modules and throws an error. The error message mentions the module path but not the import location, which makes it confusing to trace. Establishing the rule before it happens prevents the debugging session.

Adapter selection: what changes in CLAUDE.md

The adapter determines where SvelteKit deploys and what runtime APIs are available. Claude Code generates adapter-agnostic code by default, which works for Node.js but produces subtle errors on Cloudflare (no process.env, no native Node.js modules) and Vercel (edge runtime restrictions).

Declare the adapter in CLAUDE.md with its constraints:

## Adapter constraints

### adapter-node (default, self-hosted)
- Use process.env in vite.config.ts and svelte.config.js (build tools only)
- Use $env/static/private for runtime secrets
- fs, path, child_process are available in server files
- Output: build/ directory, run with node build/

### adapter-vercel
- Edge functions: do not use Node.js native modules (fs, crypto from node:crypto is OK)
- Use $env/static/private or $env/dynamic/private, not process.env in app code
- Configure edge vs serverless per-route via config.runtime in +page.server.ts
- Output: .vercel/output/, do not commit

### adapter-cloudflare
- NEVER use process.env, use $env/dynamic/private (backed by platform.env)
- No fs module access
- D1, R2, KV accessed via platform.env in load functions and actions:
  const db = event.platform?.env.DB; // D1 database
- Workers are not Node.js, no require(), use import
- Output: .cloudflare/, do not commit

With the adapter specified in CLAUDE.md, Claude will use the correct environment access pattern and avoid importing Node.js built-ins that are unavailable on the target platform.

Putting it together: Claude Code as a SvelteKit pair programmer

A SvelteKit 2.x project with the CLAUDE.md template above gives Claude enough context to generate correct code across the full server architecture. Load functions land in the correct file (server vs. universal), with await parent() for layout data inheritance and proper error() and redirect() throws for error cases. Form actions use fail() for validation and redirect() for success, with Zod parsing and preserved form values. The hooks.server.ts centralises authentication, populating event.locals.user once so every route reads it without re-querying. Environment variables use the correct $env module for their security classification. Database clients stay behind the src/lib/server/ boundary.

The underlying pattern is the same as for every other framework: Claude performs at the level of the context it receives. Without a SvelteKit CLAUDE.md, Claude applies Next.js-shaped intuitions to a framework that has explicitly rejected them. Server components become +page.ts with database imports. API routes appear where form actions belong. process.env replaces $env. Sessions appear in localStorage. Each error is small. Collectively they produce a codebase that requires significant rework before it is production-ready.

With the CLAUDE.md above, the output is consistent from the first route to the fiftieth. Add the template, run one complete feature (layout load, page load with parent data, form action, hooks-based auth, typed locals), and adjust any rules that the first session reveals need to be more specific.

For the environment variable rules in more detail, Claude Code with environment variables covers the .env file structure and the patterns that keep secrets out of client bundles across any framework. For TypeScript configuration that pairs with the strict mode declared in the template, Claude Code with TypeScript covers the tsconfig.json patterns. For adding auth to a SvelteKit project specifically, Claude Code with Clerk covers the Clerk SDK integration, which plugs into hooks.server.ts with a single middleware call. For the testing split between Vitest and Playwright in SvelteKit, Claude Code with Vitest and Claude Code with Playwright cover the respective configurations.

Claudify includes a SvelteKit-specific CLAUDE.md template pre-configured for the load function split, form actions, hooks middleware, adapter constraints, and the $env access rules that keep secrets off the client.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir