ai svelte tutorial 2026-08-30

By Xah Lee. Date: .
work in progress

write a tutorial for Svelte

ai answer

Svelte Tutorial (Svelte 5 + SvelteKit)

A practical path from zero to a working app using Svelte 5 runes and SvelteKit. Svelte is currently on the 5.x line (5.57 at writing). New projects should use runes, not Svelte 4’s implicit let / $:.

Official places to keep open while you work:

A downloadable copy of this guide is at the end.


1. What Svelte is

Svelte is a compiler. You write components that look like HTML, CSS, and JavaScript. At build time those files become small modules that update the DOM directly. There is no virtual DOM in production.

Svelte = components, reactivity, templating
SvelteKit = routing, SSR, data loading, forms, deploy adapters

For a real app, start with SvelteKit. You can still drop Svelte into an existing Vite project if you only need a widget.


2. Create a project

npx sv create my-app
cd my-app
npm install
npm run dev

Open http://localhost:5173. The CLI can add TypeScript, ESLint, Prettier, and Playwright — TypeScript is worth taking.

my-app/
├── src/
│   ├── lib/                 # shared code ($lib)
│   ├── routes/              # pages and layouts
│   │   ├── +layout.svelte
│   │   └── +page.svelte
│   └── app.html
├── static/
└── package.json
Script Purpose
npm run dev Vite + HMR
npm run build Production build
npm run preview Serve that build
npx sv check Check Svelte/TS

3. A component

Three optional blocks in a .svelte file:

<script>
	function greet() {
		alert('Welcome to Svelte!');
	}
</script>

<button onclick={greet}>Click me</button>

<style>
	button {
		font-size: 1.25rem;
	}
</style>

Notes that trip people coming from Svelte 4 or React:


4. Runes: the reactivity model

Runes look like $functions. You do not import them. The compiler reads them.

$state — values that drive the UI

<script>
	let count = $state(0);
</script>

<button onclick={() => count++}>
	Clicked {count} times
</button>

Only wrap values that should trigger updates. Objects and arrays passed to $state are deeply reactive, so todos.push(...) or todo.done = true updates the view. Use $state.raw(...) for large payloads you replace wholesale (typical API responses).

$derived — computed values

<script>
	let count = $state(0);
	let doubled = $derived(count * 2);
	let label = $derived(count === 1 ? 'time' : 'times');
</script>

<p>{count} {label} → {doubled}</p>

Multi-step work uses $derived.by(() => { ... }). The expression must be free of side effects.

$effect — side effects only

<script>
	let count = $state(0);

	$effect(() => {
		document.title = `Count: ${count}`;
	});
</script>

Return a function to clean up (timers, subscriptions). Do not use $effect to compute a value from other state — that is what $derived is for. That mix-up is the most common Svelte 5 mistake.

Share state in a module

Runes work in .svelte.js / .svelte.ts files:

// src/lib/counter.svelte.js
export function createCounter(initial = 0) {
	let count = $state(initial);

	return {
		get count() {
			return count;
		},
		increment() {
			count += 1;
		}
	};
}

Do not export a reassignable $state primitive from a module. Export a factory, an object, or a class instance.


5. Props, events, bindings

<!-- Badge.svelte -->
<script>
	let { label = 'New', tone = 'neutral' } = $props();
</script>

<span class="badge {tone}">{label}</span>
<Badge label="Beta" tone="accent" />

TypeScript: annotate the destructure. Values derived from props should usually be $derived, because props can change. Two-way binding into a child uses $bindable():

<script>
	let { value = $bindable('') } = $props();
</script>
<input bind:value />

Pass callbacks as props instead of createEventDispatcher:

<TodoItem {todo} ontoggle={toggle} onremove={remove} />

Common bindings: bind:value, bind:checked, bind:this.


6. Template logic

{#if count > 10}
	<p>That's a lot.</p>
{:else if count > 0}
	<p>Keep going.</p>
{:else}
	<p>Start clicking.</p>
{/if}

{#each todos as todo (todo.id)}
	<li class={{ done: todo.done }}>{todo.text}</li>
{:else}
	<li>Nothing yet.</li>
{/each}

{#await loadUser(id)}
	<p>Loading…</p>
{:then user}
	<p>{user.name}</p>
{:catch err}
	<p>{err.message}</p>
{/await}

Always key {#each} when items can move or disappear. {@html trusted} exists — only use it with trusted markup.


7. Snippets (the replacement for slots)

Reusable markup inside a component, or passed into a child:

{#snippet row(item)}
	<li>{item.name} — ${item.price.toFixed(2)}</li>
{/snippet}

<ul>
	{#each items as item}
		{@render row(item)}
	{/each}
</ul>
<!-- Card.svelte -->
<script>
	let { title, children, footer } = $props();
</script>

<article>
	<h2>{title}</h2>
	{@render children?.()}
	{#if footer}
		<footer>{@render footer()}</footer>
	{/if}
</article>
<Card title="Delete file?">
	<p>This cannot be undone.</p>
	{#snippet footer()}
		<button>Cancel</button>
		<button>Delete</button>
	{/snippet}
</Card>

Unnamed content between the tags becomes children. Slots still work as legacy syntax; new code should use snippets.


8. SvelteKit routing and data

Routes are files under src/routes. Use normal <a href="..."> links.

src/routes/
├── +layout.svelte             → chrome around every page
├── +page.svelte               → /
├── about/+page.svelte         → /about
└── blog/[slug]/+page.svelte   → /blog/:slug
<!-- src/routes/+layout.svelte -->
<script>
	let { children } = $props();
</script>

<nav>
	<a href="/">Home</a>
	<a href="/todos">Todos</a>
</nav>
<main>{@render children()}</main>

Load data before the page renders:

// src/routes/blog/[slug]/+page.js
import { error } from '@sveltejs/kit';

export function load({ params }) {
	if (params.slug === 'hello-world') {
		return { title: 'Hello world', content: 'Welcome.' };
	}
	error(404, 'Not found');
}
<script>
	let { data } = $props();
</script>

<h1>{data.title}</h1>
<p>{data.content}</p>
File Where it runs Use for
+page.js Server + browser Public data
+page.server.js Server only DB, secrets, form actions
+server.js Server API endpoints
+error.svelte Both Error UI

Keep secrets in server files. Prefer load over onMount + fetch so the first paint already has data.

Forms

// src/routes/todos/+page.server.js
export const actions = {
	create: async ({ request }) => {
		const data = await request.formData();
		const text = String(data.get('text') ?? '').trim();
		if (!text) return { error: 'Write something first.' };
		// persist…
		return { success: true };
	}
};
<script>
	let { form } = $props();
</script>

{#if form?.error}<p>{form.error}</p>{/if}

<form method="POST" action="?/create">
	<input name="text" required />
	<button>Add</button>
</form>

The form works without JavaScript. SvelteKit enhances it when JS is available.


9. Mini project: todos

Drop this in src/routes/todos/+page.svelte (and link it from the layout). It stays in memory so you can focus on runes.

<script>
	let text = $state('');
	let filter = $state('all');
	let todos = $state([
		{ id: 1, text: 'Install SvelteKit', done: true },
		{ id: 2, text: 'Learn runes', done: false }
	]);

	let visible = $derived(
		todos.filter((t) => {
			if (filter === 'active') return !t.done;
			if (filter === 'done') return t.done;
			return true;
		})
	);
	let remaining = $derived(todos.filter((t) => !t.done).length);

	function add(e) {
		e.preventDefault();
		const value = text.trim();
		if (!value) return;
		todos.push({ id: Date.now(), text: value, done: false });
		text = '';
	}

	function toggle(id) {
		const todo = todos.find((t) => t.id === id);
		if (todo) todo.done = !todo.done;
	}

	function remove(id) {
		todos = todos.filter((t) => t.id !== id);
	}
</script>

<h1>Todos</h1>
<p>{remaining} left</p>

<form onsubmit={add}>
	<input bind:value={text} placeholder="What needs doing?" />
	<button>Add</button>
</form>

<div>
	<button onclick={() => (filter = 'all')}>All</button>
	<button onclick={() => (filter = 'active')}>Active</button>
	<button onclick={() => (filter = 'done')}>Done</button>
</div>

<ul>
	{#each visible as todo (todo.id)}
		<li class={{ done: todo.done }}>
			<label>
				<input
					type="checkbox"
					checked={todo.done}
					onchange={() => toggle(todo.id)}
				/>
				{todo.text}
			</label>
			<button onclick={() => remove(todo.id)}>×</button>
		</li>
	{:else}
		<li>Nothing here.</li>
	{/each}
</ul>

<style>
	.done {
		opacity: 0.55;
		text-decoration: line-through;
	}
</style>

That one file covers $state, $derived, bindings, events, keyed {#each}, and scoped CSS.


10. Svelte 4 → 5 map

Old New
let count = 0 let count = $state(0)
$: doubled = count * 2 let doubled = $derived(count * 2)
$: console.log(count) $effect(() => console.log(count))
export let name let { name } = $props()
on:click={fn} onclick={fn}
<slot /> {@render children()}
writable for local state $state

Svelte 5 still compiles old syntax, so mixed codebases work. New files should be runes-only.

Habits that pay off

  1. $derived for values, $effect for the outside world.
  2. Key lists.
  3. Callbacks as props.
  4. Snippets instead of slots.
  5. Server files for secrets and writes.
  6. <a> and <form> first; let the framework enhance them.

11. What to do next

  1. Walk the official tutorial — Basic Svelte, then Kit.
  2. Persist the todo list with a +page.server.js action.
  3. Split out TodoItem.svelte.
  4. Move shared state into a .svelte.js module.
  5. Deploy with the adapter your host wants (adapter-auto is the scaffold default).

Svelte’s bet is unchanged: stay close to HTML/CSS/JS, ship less runtime, let the compiler do the bookkeeping. Runes make that bookkeeping explicit enough to share across files without stores for most apps.


Downloadable markdown version:

If you want a follow-up, say which direction: a longer SvelteKit app (auth + DB), a React-to-Svelte migration sheet, or a TypeScript-first version of the same tutorial.