SvelteKit 2 paired with Svelte 5's rune system is one of the most productive full-stack setups available in 2026. Bundles are 35% smaller than Svelte 4, the reactivity model is explicit and composable, and the framework handles routing, server functions, form actions, and auth in a way that feels coherent rather than bolted together.
This guide covers everything you need to build and deploy a real SvelteKit application — from file routing to production auth.
Setup
npx sv create my-app
cd my-app
npm install
npm run dev
Choose the full SvelteKit demo to see file routing, server functions, and form actions in action from the start.
Current stable versions: SvelteKit 2.57.1, Svelte 5.55.0 (May 2026).
TypeScript support is first-class — the template includes full type inference for routes, load functions, and form actions.
Svelte 5 Runes
Svelte 5 replaced the implicit reactive store system with explicit runes — compiler directives that work in .svelte and .svelte.ts files:
<script lang="ts">
// $state: reactive variable (replaces reactive let)
let count = $state(0)
let user = $state<{ name: string; email: string } | null>(null)
// $derived: computed from other state (replaces $: label)
let doubled = $derived(count * 2)
let displayName = $derived(user?.name ?? 'Guest')
// $derived.by: computed with complex logic
let stats = $derived.by(() => {
return {
total: count,
doubled,
isHigh: count > 100
}
})
// $effect: side effects with automatic cleanup
$effect(() => {
const timer = setInterval(() => count++, 1000)
return () => clearInterval(timer) // cleanup on destroy
})
</script>
<h1>Count: {count} (×2 = {doubled})</h1>
<p>Welcome, {displayName}</p>
<button onclick={() => count++}>Increment</button>
The key shift: state is now always explicit. If you assign let count = $state(0), it's reactive. If you use plain let count = 0, it's not. No magic — the compiler only tracks what you mark.
Component Props with $props
<script lang="ts">
// Typed props with defaults
interface Props {
title: string
items: string[]
variant?: 'default' | 'compact'
onSelect?: (item: string) => void
}
let { title, items, variant = 'default', onSelect }: Props = $props()
</script>
<div class="list list--{variant}">
<h2>{title}</h2>
{#each items as item}
<button onclick={() => onSelect?.(item)}>{item}</button>
{/each}
</div>
$props() replaces the export let pattern. The TypeScript interface gives you autocomplete and type checking at the call site.
File Routing
SvelteKit uses the filesystem as the router:
src/routes/
+page.svelte → /
+layout.svelte → shared layout for all routes
+layout.server.ts → server-side layout load (auth, etc.)
blog/
+page.svelte → /blog
+page.server.ts → server load function for /blog
[slug]/
+page.svelte → /blog/:slug
+page.server.ts → server load for /blog/:slug
api/
posts/
+server.ts → REST endpoint: GET/POST /api/posts
(auth)/ → route group (no URL segment)
login/
+page.svelte → /login
+page.server.ts → form actions for login
The parentheses (auth) create a layout group without adding to the URL path. Useful for sharing auth-specific layouts across multiple routes.
Server Load Functions
+page.server.ts runs exclusively on the server. It fetches data and returns it to +page.svelte:
// src/routes/blog/[slug]/+page.server.ts
import { db } from '$lib/server/db'
import { error } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ params, locals }) => {
const post = await db
.selectFrom('posts')
.selectAll()
.where('slug', '=', params.slug)
.where('published', '=', true)
.executeTakeFirst()
if (!post) {
error(404, { message: 'Post not found' })
}
// Access auth state set by hooks.server.ts
const isAuthor = locals.user?.id === post.author_id
return {
post,
isAuthor
}
}
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types'
let { data }: { data: PageData } = $props()
// data.post and data.isAuthor are fully typed
</script>
<article>
<h1>{data.post.title}</h1>
<p>{data.post.content}</p>
{#if data.isAuthor}
<a href="/blog/{data.post.slug}/edit">Edit</a>
{/if}
</article>
The $types import is auto-generated by SvelteKit based on your load function's return type. No manual type definitions needed.
Layout Load Functions for Auth
The layout load runs before any route load in that subtree. Use it to protect routes:
// src/routes/(protected)/+layout.server.ts
import { redirect } from '@sveltejs/kit'
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!locals.user) {
// Redirect to login, preserving intended destination
redirect(302, `/login?next=${encodeURIComponent(url.pathname)}`)
}
return {
user: locals.user
}
}
All routes under (protected)/ automatically require authentication. No per-route auth checks.
Form Actions
Form actions handle mutations. They run on the server, support progressive enhancement, and integrate with SvelteKit's error handling:
// src/routes/(auth)/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit'
import { z } from 'zod'
import { db } from '$lib/server/db'
import { verifyPassword, createSession } from '$lib/server/auth'
import type { Actions, PageServerLoad } from './$types'
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
export const load: PageServerLoad = async ({ locals }) => {
// Already logged in → redirect to dashboard
if (locals.user) redirect(302, '/dashboard')
return {}
}
export const actions: Actions = {
default: async ({ request, cookies }) => {
const formData = Object.fromEntries(await request.formData())
const parsed = loginSchema.safeParse(formData)
if (!parsed.success) {
return fail(400, {
email: formData.email as string,
error: 'Invalid email or password format'
})
}
const { email, password } = parsed.data
const user = await db
.selectFrom('users')
.select(['id', 'email', 'password_hash'])
.where('email', '=', email)
.executeTakeFirst()
if (!user || !(await verifyPassword(password, user.password_hash))) {
return fail(400, {
email,
error: 'Invalid email or password'
})
}
const token = await createSession(user.id)
cookies.set('session', token, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 days
})
redirect(302, '/dashboard')
}
}
<!-- src/routes/(auth)/login/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms'
import type { PageData, ActionData } from './$types'
let { data, form }: { data: PageData; form: ActionData } = $props()
</script>
<!-- use:enhance prevents full-page reload -->
<form method="POST" use:enhance>
<label>
Email
<input
type="email"
name="email"
value={form?.email ?? ''}
required
/>
</label>
<label>
Password
<input type="password" name="password" required />
</label>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<button type="submit">Log in</button>
</form>
use:enhance intercepts the form submission and handles it via fetch, updating the page without a full reload. The server action still runs — you get progressive enhancement for free.
API Endpoints
+server.ts creates REST endpoints:
// src/routes/api/posts/+server.ts
import { json, error } from '@sveltejs/kit'
import { db } from '$lib/server/db'
import { z } from 'zod'
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async ({ url, locals }) => {
const page = Number(url.searchParams.get('page') ?? '1')
const limit = Math.min(Number(url.searchParams.get('limit') ?? '20'), 100)
const posts = await db
.selectFrom('posts')
.select(['id', 'title', 'slug', 'created_at'])
.where('published', '=', true)
.orderBy('created_at', 'desc')
.limit(limit)
.offset((page - 1) * limit)
.execute()
return json({ posts, page, limit })
}
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false)
})
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
error(401, 'Unauthorized')
}
const body = await request.json()
const parsed = createPostSchema.safeParse(body)
if (!parsed.success) {
error(400, parsed.error.flatten().fieldErrors)
}
const post = await db
.insertInto('posts')
.values({
...parsed.data,
author_id: locals.user.id,
slug: parsed.data.title.toLowerCase().replace(/\s+/g, '-')
})
.returningAll()
.executeTakeFirstOrThrow()
return json(post, { status: 201 })
}
Auth Hooks
hooks.server.ts runs on every request — the right place to validate sessions and set locals:
// src/hooks.server.ts
import { db } from '$lib/server/db'
import type { Handle } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
const sessionToken = event.cookies.get('session')
if (sessionToken) {
const session = await db
.selectFrom('sessions')
.innerJoin('users', 'users.id', 'sessions.user_id')
.select(['users.id', 'users.email', 'users.name', 'sessions.expires_at'])
.where('sessions.token', '=', sessionToken)
.where('sessions.expires_at', '>', new Date())
.executeTakeFirst()
if (session) {
event.locals.user = {
id: session.id,
email: session.email,
name: session.name
}
} else {
// Invalid or expired session — clear the cookie
event.cookies.delete('session', { path: '/' })
}
}
return resolve(event)
}
Type the locals to get autocomplete everywhere:
// src/app.d.ts
declare global {
namespace App {
interface Locals {
user: {
id: string
email: string
name: string
} | null
}
}
}
export {}
Now locals.user is typed in all load functions and API handlers.
Environment Variables
// Server-only secrets (never exposed to browser)
import { DATABASE_URL, JWT_SECRET } from '$env/static/private'
// Public config (safe to expose)
import { PUBLIC_APP_URL, PUBLIC_POSTHOG_KEY } from '$env/static/public'
// Dynamic env (for edge runtimes, read at request time)
import { env } from '$env/dynamic/private'
const apiKey = env.OPENAI_API_KEY
SvelteKit validates environment variables at build time. If DATABASE_URL is undefined, the build fails — no runtime surprises.
Adapters: Deploying Everywhere
# Vercel (zero config)
npm install @sveltejs/adapter-vercel
# Node.js server (VPS, Railway, Fly.io)
npm install @sveltejs/adapter-node
# Static export (no server)
npm install @sveltejs/adapter-static
# Netlify
npm install @sveltejs/adapter-netlify
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel'
export default {
kit: {
adapter: adapter({
runtime: 'nodejs22.x'
})
}
}
For adapter-node, build with npm run build and run with node build/index.js. The output is a single Node.js server — straightforward to containerize.
SvelteKit vs Next.js
| Feature | SvelteKit | Next.js |
|---|---|---|
| Bundle size | Smaller (no VDOM) | Larger |
| Reactivity | Runes (compile-time) | Hooks (runtime) |
| Data fetching | load() + server actions | Server Components + actions |
| Auth pattern | hooks.server.ts | middleware.ts |
| Edge runtime | adapter-cloudflare | middleware auto |
| Learning curve | Lower | Higher (RSC model) |
| Ecosystem | Smaller | Massive |
| TypeScript | Generated types | Manual in places |
SvelteKit is the better choice when bundle size matters, you're starting a new project without heavy React ecosystem dependencies, or you want a more coherent mental model. Next.js wins for large teams with existing React expertise or when you need Vercel's edge network features deeply integrated.
Key Patterns That Save Time
Co-locate server and client code. The +page.server.ts / +page.svelte pair keeps the data contract visible. You always know where data comes from.
Form actions over API routes for mutations. Form actions handle validation, errors, and redirects in a single function. API routes add unnecessary indirection for UI interactions.
Type your locals early. Setting up app.d.ts on day one means every load function, hook, and handler has typed user context without casting.
Use $derived instead of $effect for computed values. $effect for computing values (instead of side effects) leads to update loops. If you're computing something from state, it belongs in $derived.
Full article at stacknotice.com/blog/sveltekit-complete-guide-2026
Top comments (0)