Skip to content

Instantly share code, notes, and snippets.

@ca0v
Last active June 15, 2026 20:13
Show Gist options
  • Select an option

  • Save ca0v/8692b3e4e66fa3a3842ee013f76d37f7 to your computer and use it in GitHub Desktop.

Select an option

Save ca0v/8692b3e4e66fa3a3842ee013f76d37f7 to your computer and use it in GitHub Desktop.
SvelteKit project management guide

SvelteKit project management guide

SvelteKit Project Management Guide

A practical reference for keeping a SvelteKit application well-structured, testable, and maintainable.


Project structure

src/
  lib/
    components/    # reusable UI components (.svelte)
    server/        # server-only modules (never imported by client code)
    stores/        # writable/derived stores for shared client state
    utils/         # pure functions, no Svelte dependency
  routes/
    +layout.svelte        # shell (nav, providers)
    +layout.server.ts     # load() runs on every navigation, good for auth
    +page.svelte          # route UI
    +page.server.ts       # server load() + form actions
    +page.ts              # universal load() (runs on server + client)
    +error.svelte         # per-route error boundary
  app.html              # HTML template
  app.d.ts              # ambient type declarations (Locals, PageData, etc.)
static/               # served verbatim, no processing

Keep src/lib/server/ for anything that must not reach the browser (DB clients, secrets). SvelteKit's module boundary check will error at build time if a $lib/server import leaks into a client module.


TypeScript

Always use TypeScript. Generate types from the router:

# Automatically generated into .svelte-kit/types — never edit these
# Import them in your route files:
import type { PageServerLoad, Actions } from './$types'

Declare app-wide types in src/app.d.ts:

declare global {
  namespace App {
    interface Locals { user: User | null }
    interface PageData {}
    interface Error { message: string; code?: string }
  }
}

Load functions

Use +page.server.ts for data that requires secrets or DB access. Use +page.ts for data that can be fetched on both server and client (e.g. public API calls — avoids a waterfall on client navigation).

// +page.server.ts
export const load: PageServerLoad = async ({ locals, params }) => {
  if (!locals.user) redirect(303, '/login')
  return { items: await db.getItems(params.id) }
}

Return only serializable data from load() — no class instances, no functions. SvelteKit dehydrates the return value to JSON for client hydration.


Form actions

Prefer form actions over fetch for mutations. They work without JavaScript, integrate with SvelteKit's progressive enhancement, and automatically invalidate load data on success.

// +page.server.ts
export const actions: Actions = {
  create: async ({ request, locals }) => {
    const data = await request.formData()
    const title = data.get('title')
    if (!title) return fail(400, { title, missing: true })
    await db.create({ title, userId: locals.user.id })
    // no redirect needed — SvelteKit re-runs load() automatically
  }
}
<!-- +page.svelte -->
<form method="POST" action="?/create" use:enhance>
  <input name="title" />
  {#if form?.missing}<p>Title is required</p>{/if}
  <button>Create</button>
</form>

use:enhance from $app/forms progressively upgrades the form to a fetch call with optimistic UI support.


State management

Use Svelte stores for state shared across components. Avoid global stores for data that belongs in load() — prefer the page store:

import { page } from '$app/stores'
$: user = $page.data.user  // typed, reactive, comes from layout load

For UI-only state (modal open, selected tab), keep it local to the component with let. Only lift to a store when two unrelated components genuinely need to share it.

Server-side state must live in event.locals (set in hooks.server.ts), never in module-level variables — the server is long-lived and module globals are shared across all requests.


Authentication

Handle auth in hooks.server.ts:

// src/hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
  const token = event.cookies.get('session')
  event.locals.user = token ? await validateSession(token) : null
  return resolve(event)
}

Guard routes in their +layout.server.ts or +page.server.ts load functions — not in the hook, which runs for every request including static assets.


API routes

Use +server.ts for JSON endpoints (called by external clients or client-side fetch):

// src/routes/api/items/+server.ts
export const GET: RequestHandler = async ({ locals }) => {
  const items = await db.getItems(locals.user.id)
  return json(items)
}

Prefer form actions over API routes for mutations triggered by your own UI. Reserve +server.ts for webhooks, external integrations, or endpoints consumed by non-Svelte clients.


Component patterns

  • Props via export let — declare with types; SvelteKit will warn on missing required props in dev
  • Events via createEventDispatcher (Svelte 4) or $props callbacks (Svelte 5 runes) — never mutate parent state directly
  • Slots for composition — prefer slots over prop-drilling component references
  • $effect / $derived (Svelte 5) replace $: reactivity blocks — use them for side effects and derived state respectively; never use them to trigger mutations

Keep components small. If a .svelte file exceeds ~150 lines, extract child components or move logic into a +page.ts/store.


Testing

# Unit tests (Vitest)
npx vitest

# Component tests (Vitest + @testing-library/svelte)
# E2E (Playwright)
npx playwright test

Test load functions and actions directly — they are plain async functions:

import { load } from './+page.server'
it('redirects unauthenticated users', async () => {
  await expect(load({ locals: { user: null } } as any)).rejects.toMatchObject({ status: 303 })
})

For component tests, use @testing-library/svelte over Svelte's internal test utilities — it tests behavior, not implementation.


Environment variables

.env                  # default, committed (non-secret defaults only)
.env.local            # gitignored, local overrides
.env.production       # committed, production non-secrets

Access in server code: import { MY_SECRET } from '$env/static/private'
Access in client code: import { PUBLIC_API_URL } from '$env/static/public'

SvelteKit errors at build time if a private env var is imported in a client module. Never use process.env directly — the env module handles SSR/CSR correctly and gives you type-safe autocompletion.


Build and deploy

bun run dev          # dev server with HMR
bun run build        # production build (adapter-dependent)
bun run preview      # preview the production build locally

Choose the adapter that matches your deployment target:

Target Adapter
Node server @sveltejs/adapter-node
Vercel / Netlify @sveltejs/adapter-vercel / adapter-netlify
Static site @sveltejs/adapter-static
Cloudflare @sveltejs/adapter-cloudflare

Set adapter in svelte.config.js. The wrong adapter is the most common cause of "works in dev, broken in production."


Common pitfalls

Pitfall Fix
Module-level server state Move to event.locals or DB
window/document in SSR Guard with if (browser) from $app/environment
Importing server code in client Move to $lib/server/, SvelteKit will catch it
Stale data after mutation Return from action (triggers re-run of load) or call invalidate()
fetch in load losing cookies Use the fetch provided by the load event, not global fetch
Hydration mismatch Avoid rendering random/date-dependent content on server without matching client

Checklist for a new feature

  • Data fetching in load(), mutations in actions or +server.ts
  • Auth guard in load() — not in the component
  • Secrets only in $env/static/private or $lib/server/
  • use:enhance on forms for progressive enhancement
  • Unit test for the load/action, Playwright test for the happy path
  • No module-level mutable state on the server

Field notes (Svelte 5 + Vite, learned the hard way)

These are things that bit us on a real Svelte 5 / SvelteKit app and aren't obvious from the docs.

Run svelte-check, not just tsc

tsc --noEmit does not catch Svelte-template type errors (bad bindings, wrong slot props, $props mismatches, store-typing slips). Svelte ships its own checker:

svelte-check --tsconfig ./tsconfig.json

Wire it into the pre-commit/CI gate alongside tsc. Treating "tsc is green" as "types are green" lets real errors through.

Svelte 5: prefer $app/state over $app/stores

In Svelte 5, $app/stores (the $page store shown above) is superseded by $app/state, which exposes page as a plain rune-backed object — no $ prefix, no subscription:

<script>
  import { page } from '$app/state'   // not '$app/stores'
</script>
{page.data.user?.name}

$app/stores still works but is on the deprecation path. New code should use $app/state.

Runes-based shared state lives in .svelte.ts modules

Svelte 5 lets you keep cross-component client state in a plain module using runes — often cleaner than a writable store, and it can transparently persist to localStorage:

// lib/chartState.svelte.ts
function createChartState() {
  let chartType = $state<'bar' | 'line'>('bar');
  // hydrate from localStorage on init; $effect.root + persist on change
  return {
    get chartType() { return chartType; },
    set chartType(v) { chartType = v; localStorage.setItem('chartType', v); },
  };
}
export const chartState = createChartState();

Note the .svelte.ts extension — runes only compile in .svelte/.svelte.ts files; a plain .ts module silently won't react.

Vite dev-server plugin/middleware code is cached for the process lifetime

HMR only reloads browser-facing modules. Anything imported by a custom Vite plugin or configureServer middleware (request handlers, prompt builders, server-side helpers) is loaded once when the dev server boots and is not re-read on edit. After changing such a module you must restart the dev server — editing and refreshing the browser does nothing. This produces baffling "my change had no effect" sessions.

new URL(path) needs an absolute base — relative paths only work with fetch

When your API base is empty in production (same-origin), fetch('/data/x') is fine but new URL('/data/x') throws TypeError: Invalid URL (the one-arg constructor requires an absolute URL). Always supply a base:

const base = import.meta.env.VITE_API_URL || window.location.origin;
const url = new URL(`${base}/data/query`);
url.searchParams.set('source', src);

This is a classic dev-passes/prod-throws bug, since dev usually has a non-empty VITE_API_URL.

D3 (or any imperative lib) ↔ Svelte: pick one owner per DOM subtree

When mixing Svelte with a library that mutates the DOM itself (D3, Leaflet, a chart lib), draw a hard ownership line: the library owns its SVG/canvas subtree, Svelte never reaches inside it (and vice versa). For UI that must overlay library-owned DOM — e.g. an inline label editor over a D3 <svg> — use an absolutely-positioned Svelte <input> in a position:relative wrapper rather than <foreignObject>/contenteditable inside the library's SVG. Editing inside library-owned nodes fights the library's own re-render and causes caret/focus loss.

{#each} needs a stable key when the list reorders

{#each items as item (item.id)} — without the (key), Svelte reuses DOM nodes by index, so reordering or deleting mid-list keeps stale component state and bound inputs attached to the wrong row. Always key {#each} over anything that can reorder or splice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment