SvelteKit, the fullstack application framework for Svelte
SvelteKit is the official framework for building Svelte applications: routing derived from directory structure, server side data loading, form handling without writing your own API, and deployment to a chosen platform through a swappable adapter. The current version is 2.70.2, and the Svelte it builds on is at 5.56.8. Both packages are MIT licensed.
What sets this apart from other frameworks
The fundamental difference sits one level down, in Svelte itself, and is worth naming because everything else follows from it.
Svelte is a compiler rather than a library shipped with your application. Component code turns at build time into ordinary JavaScript operating directly on the document, so no intermediary layer comparing element trees reaches the browser. The result is a smaller bundle and less startup work, though the gap is narrower today than older comparisons suggest, since the competition has trimmed its own overhead too.
The second thing is the approach to forms, where this framework genuinely stands out. Form actions let you handle a submission without a separate API route and without client side code. The form works even when JavaScript fails to load, because underneath it is an ordinary form submission that the client layer merely enhances. What other frameworks require you to build deliberately is the default here.
The third is adapters. The same application code deploys to a serverless platform, to your own Node.js server, or as a static site, by changing one configuration entry. It is not costless magic, since some capabilities depend on the target environment, but moving between providers is markedly cheaper here than in solutions tied to one platform.
Remote functions, the newest change
This is the freshest piece worth knowing, because it changes how the data layer is written and is simultaneously marked experimental.
Remote functions let you call server code directly from a component, with no API route to define and no request handling to write. You write a function, mark it as a query, and call it in a component, while the framework handles the request, typing, and caching.
Two details are worth knowing, since older tutorials carry stale information. The method that used to run a query was removed in release 2.61.0 and replaced by awaiting the result directly in every context. A variant maintaining a long lived subscription also arrived, useful for data that changes over time.
There is a caveat that has to be stated plainly, though. The mechanism requires enabling two experimental options at once: async support in the compiler and remote functions in the framework configuration. Those two are coupled, and attempting to use queries with async disabled will not work. On a production project, treat it like any experimental feature: it can be built on, but the interface may still change.
What is SvelteKit?
SvelteKit is the official fullstack framework for Svelte, designed for building modern web applications. It combines the elegance and simplicity of Svelte with powerful server-side capabilities - SSR, file-based routing, form actions, and API endpoints. All of this with minimal boilerplate and incredible performance thanks to its unique compiler-based approach.
Unlike other frameworks, Svelte compiles components into native JavaScript at build time, eliminating the need to include a runtime in the bundle. This means smaller files, faster loading, and better performance. SvelteKit builds on this architecture and adds routing, data loading, form handling, and deployment adapters.
Why SvelteKit?
Key advantages of SvelteKit
- No runtime - The compiler generates native JavaScript
- Fastest framework - Smaller bundles, faster hydration
- Simple mental model - Intuitive syntax with no boilerplate
- File-based routing - Folder structure = URL structure
- Form actions - Native forms with progressive enhancement
- Universal - SSR, SSG, SPA, all in one
- Svelte 5 Runes - Modern reactivity system
- Vite under the hood - Lightning-fast dev server
SvelteKit vs other frameworks
| Feature | SvelteKit | Next.js | Nuxt | Remix |
|---|---|---|---|---|
| Runtime | None | React | Vue | React |
| Bundle size | Smallest | Medium | Medium | Small |
| Learning curve | Easy | Medium | Medium | Medium |
| Form handling | Native | Client-side | Client-side | Native |
| Compiler | Yes | No | No | No |
| Dev experience | Excellent | Good | Good | Good |
| TypeScript | Full | Full | Full | Full |
| Deploy adapters | Many | Vercel-first | Many | Many |
When to choose SvelteKit?
- Smaller teams - simpler syntax, less code
- Performance-critical - smallest bundles
- Progressive enhancement - native forms
- Fast development - HMR via Vite
- New projects - modern architecture
Installation and configuration
Creating a new project
npx sv create my-app
# The interactive wizard will ask about:
# - Template (skeleton, demo app, library)
# - TypeScript (yes/no)
# - Add-ons (Tailwind, ESLint, Prettier, Playwright, Vitest)
cd my-app
npm install
npm run devAlternative methods
# With pnpm
pnpm dlx sv create my-app
# With yarn
yarn dlx sv create my-app
# With bunx
bunx sv create my-app
# The create-svelte package behind "pnpm create svelte"
# and "yarn create svelte" is deprecated.
# npm prints a notice pointing at sv instead.Project structure
my-app/
βββ src/
β βββ app.html # HTML template
β βββ app.css # Global styles
β βββ app.d.ts # TypeScript declarations
β βββ lib/ # Shared code ($lib alias)
β β βββ components/ # Components
β β βββ server/ # Server-only code
β β βββ utils/ # Helpers
β βββ routes/ # File-based routing
β βββ +page.svelte # Home page
β βββ +page.server.ts # Server load
β βββ +layout.svelte # Layout
β βββ +error.svelte # Error page
β βββ api/ # API endpoints
β βββ +server.ts
βββ static/ # Static assets
βββ svelte.config.js # Svelte config
βββ vite.config.ts # Vite config
βββ tsconfig.json
βββ package.jsonConfiguring svelte.config.js
// svelte.config.js
import adapter from '@sveltejs/adapter-auto'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
$components: 'src/lib/components',
$utils: 'src/lib/utils'
},
csp: {
mode: 'auto',
directives: {
'script-src': ['self']
}
},
prerender: {
handleHttpError: 'warn'
}
}
}
export default configFile-based routing
Basic structure
SvelteKit uses the folder structure inside src/routes/ to define routing:
src/routes/
βββ +page.svelte # /
βββ +layout.svelte # Layout for all pages
βββ about/
β βββ +page.svelte # /about
βββ blog/
β βββ +page.svelte # /blog
β βββ +page.server.ts # Data loading
β βββ [slug]/ # Dynamic route
β βββ +page.svelte # /blog/:slug
β βββ +page.server.ts
βββ products/
β βββ +page.svelte # /products
β βββ [...rest]/ # Catch-all route
β βββ +page.svelte # /products/*
βββ (auth)/ # Route group (does not affect URL)
βββ login/
β βββ +page.svelte # /login
βββ register/
βββ +page.svelte # /registerSpecial files
| File | Description |
|---|---|
+page.svelte | Page component |
+page.ts | Universal load function |
+page.server.ts | Server-only load function |
+layout.svelte | Layout wrapper |
+layout.ts | Layout load function |
+layout.server.ts | Server-only layout load |
+server.ts | API endpoint |
+error.svelte | Error boundary |
Dynamic routes
<!-- src/routes/blog/[slug]/+page.svelte -->
<script>
export let data
</script>
<article>
<h1>{data.post.title}</h1>
<div class="content">
{@html data.post.content}
</div>
<p>Author: {data.post.author}</p>
</article>// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params }) => {
const post = await db.post.findUnique({
where: { slug: params.slug }
})
if (!post) {
throw error(404, {
message: 'Post not found'
})
}
return { post }
}Rest parameters (catch-all)
// src/routes/docs/[...path]/+page.server.ts
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ params }) => {
// params.path = "getting-started/installation"
// for URL: /docs/getting-started/installation
const segments = params.path.split('/')
const doc = await fetchDoc(segments)
return { doc, breadcrumbs: segments }
}Optional parameters
// src/routes/[[lang]]/about/+page.server.ts
// Matches /about and /pl/about and /en/about
export const load: PageServerLoad = async ({ params }) => {
const lang = params.lang || 'pl'
return { lang }
}Route groups
Route groups (name) organize code without affecting the URL:
src/routes/
βββ (marketing)/ # Marketing group
β βββ +layout.svelte # Layout for marketing
β βββ pricing/
β β βββ +page.svelte # /pricing
β βββ features/
β βββ +page.svelte # /features
βββ (app)/ # App group
β βββ +layout.svelte # Layout for app
β βββ dashboard/
β β βββ +page.svelte # /dashboard
β βββ settings/
β βββ +page.svelte # /settingsData loading
Universal load (runs on server and client)
// src/routes/blog/+page.ts
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ fetch, params, url }) => {
// Use fetch - SvelteKit deduplicates it
const response = await fetch('/api/posts')
const posts = await response.json()
// Query parameters
const page = url.searchParams.get('page') || '1'
return {
posts,
page: parseInt(page)
}
}Server load (runs only on server)
// src/routes/blog/+page.server.ts
import type { PageServerLoad } from './$types'
import { db } from '$lib/server/database'
import { error, redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, cookies, locals }) => {
const session = cookies.get('session')
if (!session) {
throw redirect(303, '/login')
}
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' }
})
const user = locals.user
return { posts, user }
}Layout load
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ cookies }) => {
const theme = cookies.get('theme') || 'light'
return { theme }
}<!-- src/routes/+layout.svelte -->
<script>
export let data
</script>
<div class="app" data-theme={data.theme}>
<nav>Navigation</nav>
<slot />
<footer>Footer</footer>
</div>Parent data
// src/routes/dashboard/+page.ts
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ parent }) => {
// Get data from the parent layout
const { user } = await parent()
const stats = await fetchUserStats(user.id)
return { stats }
}Invalidation and reloading
<script>
import { invalidate, invalidateAll } from '$app/navigation'
async function refreshPosts() {
// Refresh a specific endpoint
await invalidate('/api/posts')
// Or refresh everything
await invalidateAll()
}
</script>
<button on:click={refreshPosts}>
Refresh posts
</button>Form actions
SvelteKit offers native forms with progressive enhancement - they work without JavaScript!
Basic form actions
// src/routes/contact/+page.server.ts
import type { Actions } from './$types'
import { fail } from '@sveltejs/kit'
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData()
const email = data.get('email')
const message = data.get('message')
if (!email || !message) {
return fail(400, {
error: 'All fields are required',
email,
message
})
}
await db.contact.create({
data: { email, message }
})
return { success: true }
}
}<!-- src/routes/contact/+page.svelte -->
<script>
export let form
</script>
{#if form?.success}
<p class="success">Message sent!</p>
{/if}
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<form method="POST">
<label>
Email:
<input
type="email"
name="email"
value={form?.email ?? ''}
required
/>
</label>
<label>
Message:
<textarea
name="message"
required
>{form?.message ?? ''}</textarea>
</label>
<button type="submit">Send</button>
</form>Named actions
// src/routes/posts/+page.server.ts
import type { Actions } from './$types'
export const actions: Actions = {
create: async ({ request }) => {
const data = await request.formData()
const title = data.get('title')
await db.post.create({ data: { title } })
return { created: true }
},
delete: async ({ request }) => {
const data = await request.formData()
const id = data.get('id')
await db.post.delete({ where: { id } })
return { deleted: true }
},
update: async ({ request }) => {
const data = await request.formData()
const id = data.get('id')
const title = data.get('title')
await db.post.update({
where: { id },
data: { title }
})
return { updated: true }
}
}<!-- src/routes/posts/+page.svelte -->
<script>
export let data
</script>
<!-- Create form - named action -->
<form method="POST" action="?/create">
<input name="title" placeholder="Post title" required />
<button>Add post</button>
</form>
<!-- Post list -->
{#each data.posts as post}
<article>
<h2>{post.title}</h2>
<!-- Delete form -->
<form method="POST" action="?/delete">
<input type="hidden" name="id" value={post.id} />
<button>Delete</button>
</form>
<!-- Update form -->
<form method="POST" action="?/update">
<input type="hidden" name="id" value={post.id} />
<input name="title" value={post.title} />
<button>Update</button>
</form>
</article>
{/each}Progressive enhancement
<script>
import { enhance } from '$app/forms'
let loading = false
</script>
<form
method="POST"
action="?/create"
use:enhance={() => {
loading = true
return async ({ result, update }) => {
loading = false
if (result.type === 'success') {
await update()
}
}
}}
>
<input name="title" disabled={loading} />
<button disabled={loading}>
{loading ? 'Adding...' : 'Add'}
</button>
</form>API endpoints
Basic endpoints
// src/routes/api/users/+server.ts
import { json, error } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async ({ url }) => {
const limit = parseInt(url.searchParams.get('limit') || '10')
const users = await db.user.findMany({ take: limit })
return json(users)
}
export const POST: RequestHandler = async ({ request }) => {
const data = await request.json()
if (!data.email || !data.name) {
throw error(400, 'Email and name are required')
}
const user = await db.user.create({ data })
return json(user, { status: 201 })
}Dynamic API routes
// src/routes/api/users/[id]/+server.ts
import { json, error } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async ({ params }) => {
const user = await db.user.findUnique({
where: { id: params.id }
})
if (!user) {
throw error(404, 'User not found')
}
return json(user)
}
export const PUT: RequestHandler = async ({ params, request }) => {
const data = await request.json()
const user = await db.user.update({
where: { id: params.id },
data
})
return json(user)
}
export const DELETE: RequestHandler = async ({ params }) => {
await db.user.delete({
where: { id: params.id }
})
return new Response(null, { status: 204 })
}Streaming responses
// src/routes/api/stream/+server.ts
import type { RequestHandler } from './$types'
export const GET: RequestHandler = async () => {
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 500))
controller.enqueue(`data: ${JSON.stringify({ count: i })}\n\n`)
}
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
}
})
}Files and upload
// src/routes/api/upload/+server.ts
import { json, error } from '@sveltejs/kit'
import { writeFile } from 'fs/promises'
import { join } from 'path'
import type { RequestHandler } from './$types'
export const POST: RequestHandler = async ({ request }) => {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
throw error(400, 'No file provided')
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
throw error(400, 'File type not allowed')
}
const buffer = Buffer.from(await file.arrayBuffer())
const filename = `${Date.now()}-${file.name}`
const path = join('static', 'uploads', filename)
await writeFile(path, buffer)
return json({ url: `/uploads/${filename}` })
}Svelte 5 Runes
Svelte 5 introduces Runes - a new reactivity system:
$state - reactive state
<script>
let count = $state(0)
let user = $state({ name: 'Jan', age: 25 })
function increment() {
count++
}
function updateUser() {
user.age++
}
</script>
<p>Count: {count}</p>
<p>User: {user.name}, {user.age} years old</p>
<button onclick={increment}>+1</button>
<button onclick={updateUser}>Birthday</button>$derived - computed values
<script>
let count = $state(0)
let double = $derived(count * 2)
let isEven = $derived(count % 2 === 0)
let summary = $derived.by(() => {
if (count === 0) return 'Zero'
if (count < 10) return 'Few'
return 'Many'
})
</script>
<p>Count: {count}</p>
<p>Double: {double}</p>
<p>Even: {isEven ? 'Yes' : 'No'}</p>
<p>Summary: {summary}</p>
<button onclick={() => count++}>+1</button>$effect - side effects
<script>
let count = $state(0)
let savedCount = $state(0)
$effect(() => {
console.log(`Count changed to ${count}`)
return () => {
console.log('Cleanup')
}
})
$effect(() => {
if (count > 10) {
savedCount = count
}
})
$effect.pre(() => {
// Runs before DOM update
})
</script>$props - component props
<!-- Child.svelte -->
<script>
let {
name,
age = 18,
onUpdate,
children
} = $props()
</script>
<div>
<h2>{name}, {age} years old</h2>
<button onclick={() => onUpdate?.(age + 1)}>
Birthday
</button>
{@render children?.()}
</div><!-- Parent.svelte -->
<script>
import Child from './Child.svelte'
let age = $state(25)
</script>
<Child
name="Anna"
{age}
onUpdate={(newAge) => age = newAge}
>
<p>This is content passed to the child</p>
</Child>$bindable - two-way binding
<!-- Input.svelte -->
<script>
let { value = $bindable() } = $props()
</script>
<input bind:value /><!-- Parent.svelte -->
<script>
import Input from './Input.svelte'
let text = $state('')
</script>
<Input bind:value={text} />
<p>Typed text: {text}</p>Hooks and middleware
Server hooks
// src/hooks.server.ts
import type { Handle, HandleFetch, HandleServerError } from '@sveltejs/kit'
export const handle: Handle = async ({ event, resolve }) => {
const session = event.cookies.get('session')
if (session) {
const user = await getUserFromSession(session)
event.locals.user = user
}
const response = await resolve(event, {
transformPageChunk: ({ html }) => html.replace(
'%theme%',
event.cookies.get('theme') || 'light'
)
})
response.headers.set('X-Custom-Header', 'value')
return response
}
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
if (request.url.startsWith('https://api.internal.com')) {
request.headers.set('Authorization', `Bearer ${API_KEY}`)
}
return fetch(request)
}
export const handleServerError: HandleServerError = async ({ error, event }) => {
console.error('Server error:', error, 'URL:', event.url)
await logError(error)
return {
message: 'A server error occurred'
}
}Client hooks
// src/hooks.client.ts
import type { HandleClientError } from '@sveltejs/kit'
export const handleClientError: HandleClientError = async ({ error, message }) => {
console.error('Client error:', error)
await trackError(error)
return {
message: 'Something went wrong'
}
}Auth middleware pattern
// src/hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit'
const publicRoutes = ['/', '/login', '/register', '/about']
export const handle: Handle = async ({ event, resolve }) => {
const session = event.cookies.get('session')
if (session) {
try {
const user = await verifySession(session)
event.locals.user = user
} catch {
event.cookies.delete('session', { path: '/' })
}
}
const isPublic = publicRoutes.some(route =>
event.url.pathname === route ||
event.url.pathname.startsWith('/api/public')
)
if (!isPublic && !event.locals.user) {
throw redirect(303, '/login')
}
return resolve(event)
}Prerendering and SSG
Prerendering configuration
// src/routes/blog/+page.ts
export const prerender = true
// src/routes/admin/+page.ts
export const prerender = falseDynamic prerendering
// src/routes/blog/[slug]/+page.server.ts
import type { PageServerLoad, EntryGenerator } from './$types'
export const entries: EntryGenerator = async () => {
const posts = await db.post.findMany({
select: { slug: true }
})
return posts.map(post => ({ slug: post.slug }))
}
export const prerender = true
export const load: PageServerLoad = async ({ params }) => {
const post = await db.post.findUnique({
where: { slug: params.slug }
})
return { post }
}SSG + ISR pattern
// svelte.config.js
const config = {
kit: {
adapter: adapter(),
prerender: {
entries: ['*'],
handleHttpError: ({ path, referrer, message }) => {
if (path.startsWith('/api/')) {
return
}
throw new Error(message)
}
}
}
}Deployment adapters
Adapter Auto (auto-detection)
npm install @sveltejs/adapter-auto// svelte.config.js
import adapter from '@sveltejs/adapter-auto'
const config = {
kit: {
adapter: adapter()
}
}Adapter Node.js
npm install @sveltejs/adapter-nodeimport adapter from '@sveltejs/adapter-node'
const config = {
kit: {
adapter: adapter({
out: 'build',
precompress: true,
envPrefix: 'MY_APP_'
})
}
}Adapter Static (SSG)
npm install @sveltejs/adapter-staticimport adapter from '@sveltejs/adapter-static'
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: '404.html',
precompress: false
})
}
}Adapter Vercel
npm install @sveltejs/adapter-vercelimport adapter from '@sveltejs/adapter-vercel'
const config = {
kit: {
adapter: adapter({
runtime: 'nodejs22.x', // or 'nodejs20.x', 'nodejs24.x', 'bun1.x'
regions: ['fra1'],
split: true,
// runtime: 'edge' still works, but its configuration variant is
// marked deprecated in the adapter, and ISR does not support it
})
}
}Adapter Cloudflare
npm install @sveltejs/adapter-cloudflareimport adapter from '@sveltejs/adapter-cloudflare'
const config = {
kit: {
adapter: adapter({
routes: {
include: ['/*'],
exclude: ['<all>']
}
})
}
}Integrations
Tailwind CSS
npx sv add tailwindcss<!-- +page.svelte -->
<script>
let count = $state(0)
</script>
<div class="min-h-screen bg-gray-100 flex items-center justify-center">
<div class="bg-white p-8 rounded-lg shadow-lg">
<h1 class="text-2xl font-bold text-gray-800 mb-4">
Counter: {count}
</h1>
<button
onclick={() => count++}
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600
transition-colors"
>
Increment
</button>
</div>
</div>Prisma ORM
npm install prisma @prisma/client
npx prisma init// src/lib/server/database.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db
}Auth.js (NextAuth for SvelteKit)
npm install @auth/sveltekit @auth/core// src/hooks.server.ts
import { SvelteKitAuth } from '@auth/sveltekit'
import GitHub from '@auth/sveltekit/providers/github'
export const { handle, signIn, signOut } = SvelteKitAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET
})
]
})<!-- src/routes/+page.svelte -->
<script>
// $app/stores works, but $app/state replaced it from SvelteKit 2.12
import { page } from '$app/state'
import { signIn, signOut } from '@auth/sveltekit/client'
</script>
{#if page.data.session}
<p>Logged in as {page.data.session.user?.email}</p>
<button onclick={() => signOut()}>Sign out</button>
{:else}
<button onclick={() => signIn('github')}>
Sign in with GitHub
</button>
{/if}Superforms
npm install sveltekit-superforms zod// src/routes/contact/+page.server.ts
import { superValidate, message } from 'sveltekit-superforms'
// zod4 for Zod 4, which is what installs by default; the zod adapter targets Zod 3
import { zod4 } from 'sveltekit-superforms/adapters'
import { z } from 'zod'
const schema = z.object({
email: z.email('Invalid email'),
message: z.string().min(10, 'Minimum 10 characters')
})
export const load = async () => {
const form = await superValidate(zod4(schema))
return { form }
}
export const actions = {
default: async ({ request }) => {
const form = await superValidate(request, zod4(schema))
if (!form.valid) {
return { form }
}
// Send email...
return message(form, 'Sent!')
}
}<!-- src/routes/contact/+page.svelte -->
<script>
import { superForm } from 'sveltekit-superforms'
export let data
const { form, errors, message, enhance } = superForm(data.form)
</script>
{#if $message}
<p class="success">{$message}</p>
{/if}
<form method="POST" use:enhance>
<label>
Email:
<input type="email" name="email" bind:value={$form.email} />
{#if $errors.email}
<span class="error">{$errors.email}</span>
{/if}
</label>
<label>
Message:
<textarea name="message" bind:value={$form.message}></textarea>
{#if $errors.message}
<span class="error">{$errors.message}</span>
{/if}
</label>
<button type="submit">Send</button>
</form>Stores and state management
Svelte stores
// src/lib/stores/cart.ts
import { writable, derived } from 'svelte/store'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
function createCartStore() {
const { subscribe, set, update } = writable<CartItem[]>([])
return {
subscribe,
addItem: (item: Omit<CartItem, 'quantity'>) => {
update(items => {
const existing = items.find(i => i.id === item.id)
if (existing) {
existing.quantity++
return [...items]
}
return [...items, { ...item, quantity: 1 }]
})
},
removeItem: (id: string) => {
update(items => items.filter(i => i.id !== id))
},
clear: () => set([])
}
}
export const cart = createCartStore()
export const cartTotal = derived(cart, $cart =>
$cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
)<script>
import { cart, cartTotal } from '$lib/stores/cart'
</script>
<p>Total: {$cartTotal} PLN</p>
{#each $cart as item}
<div>
{item.name} x{item.quantity}
<button onclick={() => cart.removeItem(item.id)}>
Remove
</button>
</div>
{/each}Context API
<!-- src/routes/+layout.svelte -->
<script>
import { setContext } from 'svelte'
const theme = $state({ mode: 'light', accent: 'blue' })
setContext('theme', {
get current() { return theme },
toggle: () => theme.mode = theme.mode === 'light' ? 'dark' : 'light'
})
</script>
<slot /><!-- Any child component -->
<script>
import { getContext } from 'svelte'
const { current, toggle } = getContext('theme')
</script>
<button onclick={toggle}>
Theme: {current.mode}
</button>Testing
Vitest for unit tests
npx sv add vitest// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice, calculateDiscount } from './utils'
describe('formatPrice', () => {
it('formats price with currency', () => {
expect(formatPrice(1234.56)).toBe('1 234,56 PLN')
})
it('handles zero', () => {
expect(formatPrice(0)).toBe('0,00 PLN')
})
})
describe('calculateDiscount', () => {
it('calculates percentage discount', () => {
expect(calculateDiscount(100, 20)).toBe(80)
})
})Playwright for E2E
The add-on configures Playwright, which runs the same test against the Chromium, Firefox, and WebKit engines and waits for an element to be ready before clicking instead of relying on artificial delays. The engines themselves come from a separate npx playwright install command, so the first CI run takes longer than the ones after it.
npx sv add playwright// tests/home.test.ts
import { expect, test } from '@playwright/test'
test('homepage has correct title', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/Home/)
})
test('can navigate to about page', async ({ page }) => {
await page.goto('/')
await page.click('text=About us')
await expect(page.url()).toContain('/about')
})
test('contact form submits successfully', async ({ page }) => {
await page.goto('/contact')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('textarea[name="message"]', 'Test message')
await page.click('button[type="submit"]')
await expect(page.locator('.success')).toBeVisible()
})Testing Library
npm install @testing-library/svelte// src/lib/components/Counter.test.ts
import { render, fireEvent } from '@testing-library/svelte'
import { describe, it, expect } from 'vitest'
import Counter from './Counter.svelte'
describe('Counter', () => {
it('renders initial count', () => {
const { getByText } = render(Counter, { props: { initial: 5 } })
expect(getByText('Count: 5')).toBeInTheDocument()
})
it('increments on click', async () => {
const { getByText, getByRole } = render(Counter)
await fireEvent.click(getByRole('button', { name: '+1' }))
expect(getByText('Count: 1')).toBeInTheDocument()
})
})Performance optimization
Lazy loading
<script>
import { onMount } from 'svelte'
let HeavyComponent = $state()
onMount(async () => {
const module = await import('./HeavyComponent.svelte')
HeavyComponent = module.default
})
</script>
{#if HeavyComponent}
<svelte:component this={HeavyComponent} />
{:else}
<p>Loading...</p>
{/if}Streaming
// src/routes/dashboard/+page.server.ts
export const load = async () => {
return {
// Immediate
user: await getUser(),
// Streamed (does not block rendering)
stats: getStats(),
notifications: getNotifications()
}
}<script>
export let data
</script>
<h1>Welcome, {data.user.name}</h1>
{#await data.stats}
<p>Loading stats...</p>
{:then stats}
<Stats {stats} />
{/await}
{#await data.notifications}
<p>Loading notifications...</p>
{:then notifications}
<Notifications {notifications} />
{/await}Service worker
// src/service-worker.ts
import { build, files, version } from '$service-worker'
const CACHE = `cache-${version}`
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then(cache => cache.addAll([...build, ...files]))
)
})
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request)
})
)
})FAQ - frequently asked questions
Is SvelteKit suitable for large projects?
Yes! SvelteKit scales excellently thanks to its modular architecture and small bundles. It is used in production by companies like The New York Times, Spotify, and Apple.
What is the difference between Svelte and SvelteKit?
Svelte is a component framework (like React/Vue), while SvelteKit is a fullstack meta-framework built on top of Svelte (like Next.js for React). SvelteKit adds routing, SSR, form actions, and API endpoints.
Can I use React components in SvelteKit?
Not directly - Svelte and React have different component models. However, you can integrate React widgets through portals or iframes.
How does reactivity work in Svelte 5?
Svelte 5 introduces Runes - $state, $derived, $effect - which are more explicit and predictable than the previous system based on $: and let. Runes work similarly to React hooks, but without the rules of hooks.
Does SvelteKit support ISR (Incremental Static Regeneration)?
SvelteKit does not have built-in ISR like Next.js, but you can achieve a similar effect through the Vercel adapter with the isr option or through revalidation on Cloudflare.
How to secure API endpoints?
Use hooks.server.ts for auth middleware, check event.locals.user in endpoints, and use form actions with CSRF protection (built into SvelteKit).
Does SvelteKit support Edge Functions?
Yes, though the picture has shifted. Cloudflare runs the application at the edge by definition, with no separate switch for it. In the Vercel adapter runtime: 'edge' still works, but the configuration variant it belongs to is marked deprecated, the supported runtime values today are nodejs20.x, nodejs22.x, nodejs24.x, and bun1.x, and the retired nodejs18.x fails the build. ISR works only in the serverless variant, so pairing it with the edge is not an option.
Is it worth starting a new project on remote functions?
It depends on your tolerance for change. The mechanism is marked experimental, requires enabling two options at once, and its interface changed within the current year. For a prototype or an internal project yes; for a product you do not want to rewrite in six months, load functions and form actions are safer.
Data loading, where mistakes cluster
The distinction between the two kinds of load function is what most newcomers trip over, and the consequences can be serious.
A universal function runs both on the server and in the browser. A server function runs on the server alone. The filename differs by one letter, and the difference in effect is fundamental: code placed in a universal function ends up in the bundle sent to users, along with everything it reaches for.
The practical rule: anything touching a database, keys, or environment variables belongs in the server variant. The framework does warn when a server module is imported into client code, but it will not save you from writing a key directly somewhere it should never appear.
The second trap concerns what such a function returns. The result has to be serialisable and restorable on the other side, so objects with methods, class instances, and references to browser elements will not survive. Return plain structures and assemble what you need inside the component.
The third is dependencies between functions. A result from a higher level flows into lower ones, so data shared across a whole section is worth fetching once at the top rather than repeating in every subpage. The reverse mistake happens too: putting a query needed on one subpage into a parent layout makes it run on every visit anywhere beneath it.
The fourth is invalidation. After an action that changes data, the framework reloads the relevant functions, but only those that declared a dependency. If a list does not refresh after saving a form, nine times out of ten that declaration is missing.
The fifth, and the most insidious, concerns streaming. A load function can return a promise instead of a ready value, so the page renders immediately while slower data arrives later. Wrapping everything that way is tempting, but then every section of the page appears separately and the interface jumps. Stream what is genuinely slow and secondary, a recommendations list beside the main content for instance, and fetch anything needed to make sense of the page normally.
SvelteKit against the alternatives
| Feature | SvelteKit | Next.js | Nuxt | Astro |
|---|---|---|---|---|
| Underlying layer | Svelte, a compiler | React | Vue | any, islands |
| Shipped code size | smallest | medium | medium | smallest for content sites |
| Forms without an API | built in | server actions | partial | limited |
| Swapping deployment platform | adapter, one line | provider dependent | adapter | adapter |
| Ecosystem size | small | largest | medium | medium |
| People available to hire | limited | high | medium | limited |
The choice is usually settled outside the feature list. If you are building an application where forms and data work form the core, and a small team decides its own technology, this framework offers the most pleasant work in the field. If the project has to live for years inside a larger organisation and pass through many hands, ecosystem and people availability weigh more than elegance, and the more popular framework wins despite worse numbers.
Documentation lives on the Svelte site, and the source in the project repository.