February 2026 — SvelteKit remains one of the most enjoyable ways to build fast, modern web applications. With Svelte 5 runes, improved remote functions, async SSR experiments maturing, better adapter support (Vercel, Cloudflare, Node), and tiny bundle sizes, it's an excellent choice for side projects, startups, and even production apps.
In this guide we'll build a custom full-stack Todo application — nothing toy-level, but not enterprise overkill either. We'll cover:
Project setup with latest CLI
File-based routing + layouts
Data loading with
+page.server.js/+page.jsForm actions & progressive enhancement
Reactive state with runes (
$state,$derived,$effect)Basic styling (Tailwind or vanilla)
Optional: remote functions for optimistic updates
By the end you'll have a solid mental model for structuring real SvelteKit apps.
1. Project Setup (2026 style)
The official way to start projects has never been smoother:
# Create new project (uses sv CLI)
npx sv create custom-todo
cd custom-todo
# During wizard choose:
# • Skeleton project (or SvelteKit demo if you want examples)
# • TypeScript → Yes
# • ESLint + Prettier → Yes
# • Playwright / Vitest → Optional (add Vitest for unit tests)
npm install
npm run dev
# → http://localhost:5173
Tip: If you want Svelte 5 runes mode explicitly (it's usually the default now in new projects), the wizard enables it automatically in recent versions.
2. Project Structure Overview
A clean custom app usually looks like this:
src/
├── routes/ # file-system router
│ ├── +layout.svelte # root layout (shared across pages)
│ ├── +layout.server.js # root load data / hooks
│ ├── +page.svelte # home page
│ ├── todos/
│ │ ├── +page.svelte
│ │ ├── +page.server.js # server-only logic
│ │ └── [id]/
│ │ └── +page.svelte # dynamic route
├── lib/
│ ├── components/ # reusable UI (TodoItem.svelte, Button.svelte…)
│ ├── server/ # server utilities, db client…
│ └── state/ # shared rune state if needed
└── app.html # document shell
3. Building the Todo List – Core Implementation
Root Layout (src/routes/+layout.svelte)
<!-- +layout.svelte -->
<script lang="ts">
import '../app.css'; // or tailwind @import
</script>
<div class="min-h-screen bg-slate-50">
<header class="bg-indigo-600 text-white p-4">
<h1 class="text-2xl font-bold">Custom Todo • 2026</h1>
</header>
<main class="max-w-2xl mx-auto p-6">
<slot />
</main>
</div>
<style>
/* global styles or :global() if needed */
</style>
Home page redirect or simple landing
For simplicity, let's make / redirect to /todos or just show a welcome.
Todo Page (src/routes/todos/+page.svelte)
<!-- src/routes/todos/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
let todos = $state(data.todos); // reactive local state from server
let newTitle = $state('');
// Derived – computed value (runs only when deps change)
let incompleteCount = $derived(
todos.filter(t => !t.completed).length
);
async function addTodo() {
if (!newTitle.trim()) return;
// Optimistic UI (we'll improve with remote fn later)
const tempId = crypto.randomUUID();
todos = [...todos, { id: tempId, title: newTitle, completed: false }];
const res = await fetch('/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle })
});
if (!res.ok) {
// rollback on error
todos = todos.filter(t => t.id !== tempId);
alert('Failed to add todo');
}
newTitle = '';
}
function toggle(id: string) {
todos = todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
);
}
</script>
<h2 class="text-3xl font-semibold mb-6">Todos ({incompleteCount} left)</h2>
<form on:submit|preventDefault={addTodo} class="flex gap-3 mb-8">
<input
bind:value={newTitle}
placeholder="What needs to be done?"
class="flex-1 px-4 py-2 border rounded-lg"
autofocus
/>
<button type="submit" class="px-6 py-2 bg-indigo-600 text-white rounded-lg">
Add
</button>
</form>
<ul class="space-y-3">
{#each todos as todo (todo.id)}
<li class="flex items-center gap-3 p-3 bg-white rounded-lg shadow-sm">
<input
type="checkbox"
checked={todo.completed}
on:change={() => toggle(todo.id)}
/>
<span class:todo-completed={todo.completed}>{todo.title}</span>
</li>
{/each}
</ul>
<style>
.todo-completed {
@apply line-through text-gray-400;
}
</style>
Server-side data & actions (src/routes/todos/+page.server.js)
// src/routes/todos/+page.server.js
import type { Actions, PageServerLoad } from './$types';
// In-memory DB (replace with drizzle, prisma, sqlite, planetscale…)
let dbTodos = $state([
{ id: '1', title: 'Learn runes', completed: true },
{ id: '2', title: 'Build custom SvelteKit app', completed: false }
]);
export const load: PageServerLoad = async () => {
return { todos: dbTodos };
};
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
const title = form.get('title')?.toString();
if (title) {
const newTodo = {
id: crypto.randomUUID(),
title,
completed: false
};
dbTodos = [...dbTodos, newTodo];
return { success: true, newTodo };
}
return { success: false };
}
};
Note: For real apps use a proper database. For quick prototypes the in-memory $state in server file works surprisingly well (just remember it's per-process).
4. Next Steps – Make It Production-Ready
Styling: Add Tailwind via
npx sv add tailwindDatabase: Drizzle + SQLite / Postgres (
npx sv add drizzle)Auth: Lucia, Better Auth, or SvelteKit-Auth
Optimistic updates + remote functions: Use SvelteKit remote functions (new button props landed early 2026)
Deployment: Vercel, Cloudflare Pages, Netlify, or Node adapter
Testing: Vitest + Playwright (already set up if you chose them)
Summary
In 2026 building custom apps with SvelteKit feels lightweight yet powerful:
File-system routing stays intuitive
Runes make reactivity predictable and portable (even in
.svelte.tsfiles)Form actions + progressive enhancement = excellent DX & UX
Adapters and hosting options keep improving
Start small, ship fast, refactor later — that's still the Svelte way.
Happy coding!
If you build something cool with this pattern, drop a link in the comments.
What are you building with SvelteKit right now? 🚀
Discussion
Sign in with GitHub to comment. Comments are moderated.