Building Your First SvelteKit App with Svelte 5 Runes

Svelte 5 brought the biggest changes to the framework since its inception. The new runes system replaces the old reactivity model with explicit, predictable state management. Combined with async components and the improved sv
CLI, building SvelteKit applications feels fundamentally different.
This tutorial walks you through creating a complete SvelteKit application using Svelte 5's runes system. You'll build a task management app that demonstrates $state
, $derived
, and $effect
runes alongside async data loading patterns.
Link to section: Prerequisites and Setup RequirementsPrerequisites and Setup Requirements
You need Node.js 18+ or Deno 1.40+. This tutorial uses Node.js, but SvelteKit works equally well with Deno if you prefer that runtime.
Install the latest Svelte CLI globally:
npm install -g sv@latest
Verify your installation:
sv --version
# Should output: sv version 0.8.12 or higher
Link to section: Creating Your Project with the sv CLICreating Your Project with the sv CLI
The new sv
CLI streamlines project creation with interactive prompts. Create your project:
npx sv create my-task-app
The CLI presents several choices. Select these options:
- Template: SvelteKit minimal
- Type checking: TypeScript
- Additional features: Tailwind CSS, ESLint, Prettier
- Package manager: npm (or your preference)
Navigate to your project and install dependencies:
cd my-task-app
npm install
npm run dev
Your development server starts at http://localhost:5173
. The sv
CLI generated a clean project structure:
my-task-app/
├── src/
│ ├── lib/
│ ├── routes/
│ │ └── +page.svelte
│ └── app.html
├── static/
├── svelte.config.js
└── package.json
Link to section: Understanding Runes: The New Reactivity SystemUnderstanding Runes: The New Reactivity System
Svelte 5 runes replace reactive declarations with explicit primitives. Instead of $:
statements, you use $state
, $derived
, and $effect
functions.
Link to section: Basic State with $stateBasic State with $state
Replace the content of src/routes/+page.svelte
with this example:
<script>
let count = $state(0);
function increment() {
count += 1;
}
function decrement() {
count -= 1;
}
</script>
<h1>Counter: {count}</h1>
<button on:click={increment}>+</button>
<button on:click={decrement}>-</button>
<style>
h1 {
color: #333;
font-size: 2rem;
}
button {
margin: 0 0.5rem;
padding: 0.5rem 1rem;
font-size: 1.2rem;
}
</style>
The $state(0)
rune creates reactive state. When count
changes, Svelte automatically updates the DOM. No $:
syntax needed.
Link to section: Computed Values with $derivedComputed Values with $derived
Add computed values that react to state changes:
<script>
let count = $state(0);
let doubled = $derived(count * 2);
let isEven = $derived(count % 2 === 0);
let status = $derived(count === 0 ? 'zero' : count > 0 ? 'positive' : 'negative');
function increment() {
count += 1;
}
function decrement() {
count -= 1;
}
</script>
<h1>Counter: {count}</h1>
<p>Doubled: {doubled}</p>
<p>Even number: {isEven}</p>
<p>Status: {status}</p>
<button on:click={increment}>+</button>
<button on:click={decrement}>-</button>
The $derived
rune creates computed values that automatically update when their dependencies change. Svelte tracks these dependencies automatically.
Link to section: Building a Task Management AppBuilding a Task Management App
Let's build something practical. Create a task management app using runes for state management.
Link to section: Creating the Task StoreCreating the Task Store
Create src/lib/stores/tasks.svelte.js
:
export class TaskStore {
tasks = $state([]);
filter = $state('all'); // 'all', 'active', 'completed'
filteredTasks = $derived(() => {
if (this.filter === 'active') {
return this.tasks.filter(task => !task.completed);
}
if (this.filter === 'completed') {
return this.tasks.filter(task => task.completed);
}
return this.tasks;
});
activeCount = $derived(this.tasks.filter(task => !task.completed).length);
completedCount = $derived(this.tasks.filter(task => task.completed).length);
addTask(text) {
const task = {
id: Date.now(),
text: text.trim(),
completed: false,
createdAt: new Date()
};
this.tasks.push(task);
}
toggleTask(id) {
const task = this.tasks.find(t => t.id === id);
if (task) {
task.completed = !task.completed;
}
}
removeTask(id) {
this.tasks = this.tasks.filter(t => t.id !== id);
}
setFilter(newFilter) {
this.filter = newFilter;
}
clearCompleted() {
this.tasks = this.tasks.filter(task => !task.completed);
}
}
export const taskStore = new TaskStore();
This class-based approach works well with runes. The $state
and $derived
runes create reactive properties that update automatically.
Link to section: Building the Task ComponentBuilding the Task Component
Create src/lib/components/TaskItem.svelte
:
<script>
const { task, onToggle, onRemove } = $props();
function handleToggle() {
onToggle(task.id);
}
function handleRemove() {
onRemove(task.id);
}
</script>
<li class="task-item" class:completed={task.completed}>
<input
type="checkbox"
checked={task.completed}
on:change={handleToggle}
/>
<span class="task-text">{task.text}</span>
<button class="remove-btn" on:click={handleRemove}>×</button>
</li>
<style>
.task-item {
display: flex;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid #eee;
gap: 0.5rem;
}
.task-item.completed .task-text {
text-decoration: line-through;
opacity: 0.6;
}
.task-text {
flex: 1;
}
.remove-btn {
background: #ff4444;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.remove-btn:hover {
background: #cc0000;
}
</style>
Notice the $props()
rune for component props. This replaces the old export let
syntax in Svelte 5.

Link to section: Main Application ComponentMain Application Component
Update src/routes/+page.svelte
:
<script>
import { taskStore } from '$lib/stores/tasks.svelte.js';
import TaskItem from '$lib/components/TaskItem.svelte';
let newTaskText = $state('');
function addTask() {
if (newTaskText.trim()) {
taskStore.addTask(newTaskText);
newTaskText = '';
}
}
function handleKeyPress(event) {
if (event.key === 'Enter') {
addTask();
}
}
// Effect to log task changes during development
$effect(() => {
console.log(`Active tasks: ${taskStore.activeCount}, Completed: ${taskStore.completedCount}`);
});
</script>
<main class="app">
<h1>Task Manager</h1>
<div class="add-task">
<input
type="text"
placeholder="What needs to be done?"
bind:value={newTaskText}
on:keypress={handleKeyPress}
/>
<button on:click={addTask}>Add Task</button>
</div>
<div class="filters">
<button
class:active={taskStore.filter === 'all'}
on:click={() => taskStore.setFilter('all')}
>
All ({taskStore.tasks.length})
</button>
<button
class:active={taskStore.filter === 'active'}
on:click={() => taskStore.setFilter('active')}
>
Active ({taskStore.activeCount})
</button>
<button
class:active={taskStore.filter === 'completed'}
on:click={() => taskStore.setFilter('completed')}
>
Completed ({taskStore.completedCount})
</button>
</div>
{#if taskStore.filteredTasks.length > 0}
<ul class="task-list">
{#each taskStore.filteredTasks as task (task.id)}
<TaskItem
{task}
onToggle={taskStore.toggleTask.bind(taskStore)}
onRemove={taskStore.removeTask.bind(taskStore)}
/>
{/each}
</ul>
{:else}
<p class="empty-state">No tasks found</p>
{/if}
{#if taskStore.completedCount > 0}
<button class="clear-completed" on:click={() => taskStore.clearCompleted()}>
Clear Completed ({taskStore.completedCount})
</button>
{/if}
</main>
<style>
.app {
max-width: 600px;
margin: 2rem auto;
padding: 0 1rem;
}
.add-task {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.add-task input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.add-task button {
padding: 0.75rem 1rem;
background: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filters button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.filters button.active {
background: #007acc;
color: white;
border-color: #007acc;
}
.task-list {
list-style: none;
padding: 0;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 1rem;
}
.empty-state {
text-align: center;
color: #666;
font-style: italic;
margin: 2rem 0;
}
.clear-completed {
background: #ff6b6b;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
</style>
The $effect
rune runs side effects when dependencies change. This example logs task counts for development purposes.
Link to section: Adding Async Data LoadingAdding Async Data Loading
Let's add persistence using localStorage and demonstrate async patterns in Svelte 5.
Update your task store to include persistence:
// src/lib/stores/tasks.svelte.js
export class TaskStore {
tasks = $state([]);
filter = $state('all');
loading = $state(false);
filteredTasks = $derived(() => {
if (this.filter === 'active') {
return this.tasks.filter(task => !task.completed);
}
if (this.filter === 'completed') {
return this.tasks.filter(task => task.completed);
}
return this.tasks;
});
activeCount = $derived(this.tasks.filter(task => !task.completed).length);
completedCount = $derived(this.tasks.filter(task => task.completed).length);
async loadTasks() {
if (typeof window === 'undefined') return;
this.loading = true;
try {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
const saved = localStorage.getItem('tasks');
if (saved) {
this.tasks = JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load tasks:', error);
} finally {
this.loading = false;
}
}
async saveTasks() {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('tasks', JSON.stringify(this.tasks));
} catch (error) {
console.error('Failed to save tasks:', error);
}
}
addTask(text) {
const task = {
id: Date.now(),
text: text.trim(),
completed: false,
createdAt: new Date()
};
this.tasks.push(task);
this.saveTasks();
}
toggleTask(id) {
const task = this.tasks.find(t => t.id === id);
if (task) {
task.completed = !task.completed;
this.saveTasks();
}
}
removeTask(id) {
this.tasks = this.tasks.filter(t => t.id !== id);
this.saveTasks();
}
setFilter(newFilter) {
this.filter = newFilter;
}
clearCompleted() {
this.tasks = this.tasks.filter(task => !task.completed);
this.saveTasks();
}
}
export const taskStore = new TaskStore();
Add loading logic to your main component. Update the script section of src/routes/+page.svelte
:
<script>
import { onMount } from 'svelte';
import { taskStore } from '$lib/stores/tasks.svelte.js';
import TaskItem from '$lib/components/TaskItem.svelte';
let newTaskText = $state('');
onMount(() => {
taskStore.loadTasks();
});
function addTask() {
if (newTaskText.trim()) {
taskStore.addTask(newTaskText);
newTaskText = '';
}
}
function handleKeyPress(event) {
if (event.key === 'Enter') {
addTask();
}
}
$effect(() => {
console.log(`Active tasks: ${taskStore.activeCount}, Completed: ${taskStore.completedCount}`);
});
</script>
Add loading state to your template, just after the filters div:
{#if taskStore.loading}
<div class="loading">Loading tasks...</div>
{:else if taskStore.filteredTasks.length > 0}
<ul class="task-list">
{#each taskStore.filteredTasks as task (task.id)}
<TaskItem
{task}
onToggle={taskStore.toggleTask.bind(taskStore)}
onRemove={taskStore.removeTask.bind(taskStore)}
/>
{/each}
</ul>
{:else}
<p class="empty-state">No tasks found</p>
{/if}
Add the loading style:
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
Link to section: Advanced Runes PatternsAdvanced Runes Patterns
Link to section: Effect Cleanup and DependenciesEffect Cleanup and Dependencies
The $effect
rune supports cleanup functions and dependency tracking:
<script>
let windowWidth = $state(0);
// Effect with cleanup
$effect(() => {
function updateWidth() {
windowWidth = window.innerWidth;
}
updateWidth();
window.addEventListener('resize', updateWidth);
// Cleanup function
return () => {
window.removeEventListener('resize', updateWidth);
};
});
// Effect that only runs when specific values change
$effect(() => {
if (taskStore.tasks.length > 0) {
document.title = `Tasks (${taskStore.activeCount} active)`;
} else {
document.title = 'Task Manager';
}
});
</script>
Link to section: Conditional Derived ValuesConditional Derived Values
Handle complex derived state with conditional logic:
<script>
let searchTerm = $state('');
let searchResults = $derived(() => {
if (!searchTerm.trim()) return [];
const term = searchTerm.toLowerCase();
return taskStore.tasks.filter(task =>
task.text.toLowerCase().includes(term)
);
});
let searchSummary = $derived(() => {
if (!searchTerm.trim()) return '';
return `Found ${searchResults.length} task${searchResults.length !== 1 ? 's' : ''} matching "${searchTerm}"`;
});
</script>
Link to section: Testing Your Runes-Based AppTesting Your Runes-Based App
Create a test file src/lib/stores/tasks.test.js
:
import { describe, it, expect, beforeEach } from 'vitest';
import { TaskStore } from './tasks.svelte.js';
describe('TaskStore', () => {
let store;
beforeEach(() => {
store = new TaskStore();
});
it('should add tasks', () => {
store.addTask('Test task');
expect(store.tasks).toHaveLength(1);
expect(store.tasks[0].text).toBe('Test task');
expect(store.activeCount).toBe(1);
});
it('should toggle task completion', () => {
store.addTask('Test task');
const taskId = store.tasks[0].id;
store.toggleTask(taskId);
expect(store.tasks[0].completed).toBe(true);
expect(store.activeCount).toBe(0);
expect(store.completedCount).toBe(1);
});
it('should filter tasks correctly', () => {
store.addTask('Task 1');
store.addTask('Task 2');
store.toggleTask(store.tasks[0].id);
store.setFilter('active');
expect(store.filteredTasks).toHaveLength(1);
expect(store.filteredTasks[0].text).toBe('Task 2');
store.setFilter('completed');
expect(store.filteredTasks).toHaveLength(1);
expect(store.filteredTasks[0].text).toBe('Task 1');
});
});
Run tests with:
npm run test
Link to section: Production Build and DeploymentProduction Build and Deployment
Build your application for production:
npm run build
The build process creates an optimized version in the build
directory. Svelte 5 generates smaller bundles than previous versions due to improved tree-shaking and the compiler optimizations for runes.
For deployment to Vercel, create vercel.json
:
{
"buildCommand": "npm run build",
"outputDirectory": "build",
"framework": "sveltekit"
}
Deploy with:
npx vercel --prod
Link to section: Migration Considerations from Svelte 4Migration Considerations from Svelte 4
If you're migrating existing Svelte 4 code, here are the key changes:
Link to section: Reactive Declarations to $derivedReactive Declarations to $derived
Svelte 4:
<script>
export let count = 0;
$: doubled = count * 2;
$: isEven = count % 2 === 0;
</script>
Svelte 5:
<script>
let { count } = $props();
let doubled = $derived(count * 2);
let isEven = $derived(count % 2 === 0);
</script>
Link to section: Store Subscriptions to RunesStore Subscriptions to Runes
Svelte 4:
<script>
import { writable } from 'svelte/store';
const count = writable(0);
$: $count, console.log('Count changed');
</script>
Svelte 5:
<script>
let count = $state(0);
$effect(() => {
console.log('Count changed:', count);
});
</script>
Link to section: Props Declaration ChangesProps Declaration Changes
Svelte 4:
<script>
export let title;
export let optional = 'default';
</script>
Svelte 5:
<script>
let { title, optional = 'default' } = $props();
</script>
Link to section: Performance Benefits and Bundle SizePerformance Benefits and Bundle Size
Svelte 5 with runes produces smaller bundles and faster runtime performance. In our task app, the production build is approximately 15KB gzipped, compared to <25KB
for equivalent React applications.
The explicit nature of runes helps Svelte's compiler generate more efficient code. Updates happen directly without virtual DOM diffing, and the compiler can better analyze dependency graphs.
Link to section: Troubleshooting Common IssuesTroubleshooting Common Issues
Link to section: Rune Usage Outside ComponentsRune Usage Outside Components
Error: $state can only be used inside a svelte component
Solution: Ensure runes are used in .svelte
files or .svelte.js
files that export classes or functions used by components.
Link to section: Stale Closures in EffectsStale Closures in Effects
Error: Effect doesn't update when expected
Solution: Make sure you're reading reactive state inside the effect:
// Wrong - captures initial value
let value = count;
$effect(() => {
console.log(value); // Always logs initial value
});
// Correct - reads current value
$effect(() => {
console.log(count); // Logs current value
});
Link to section: TypeScript Issues with PropsTypeScript Issues with Props
Error: Property doesn't exist on props object
Solution: Define proper TypeScript interfaces:
interface Props {
title: string;
optional?: string;
}
let { title, optional = 'default' }: Props = $props();
Svelte 5's runes system represents a fundamental shift toward explicit, predictable reactivity. This tutorial covered the core concepts through a practical application, demonstrating how runes simplify state management while improving performance. The new system takes some adjustment, but the benefits in code clarity and runtime efficiency make the learning curve worthwhile.