SvelteKit project management guide
A practical reference for keeping a SvelteKit application well-structured, testable, and maintainable.
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.
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 }
}
}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.
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.
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 loadFor 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.
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.
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.
- Props via
export let— declare with types; SvelteKit will warn on missing required props in dev - Events via
createEventDispatcher(Svelte 4) or$propscallbacks (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.
# Unit tests (Vitest)
npx vitest
# Component tests (Vitest + @testing-library/svelte)
# E2E (Playwright)
npx playwright testTest 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.
.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.
bun run dev # dev server with HMR
bun run build # production build (adapter-dependent)
bun run preview # preview the production build locallyChoose 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."
| 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 |
- Data fetching in
load(), mutations inactionsor+server.ts - Auth guard in
load()— not in the component - Secrets only in
$env/static/privateor$lib/server/ -
use:enhanceon forms for progressive enhancement - Unit test for the load/action, Playwright test for the happy path
- No module-level mutable state on the server
These are things that bit us on a real Svelte 5 / SvelteKit app and aren't obvious from the docs.
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.jsonWire it into the pre-commit/CI gate alongside tsc. Treating "tsc is green" as "types are green" lets real errors through.
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.
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.
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.
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.
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 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.