- What Not to Do Five patterns that seem smart but cause pain. Learn to recognize them before they hurt you.
- No Global Store Until Proven Guilty Most apps don't need global state. Learn when route-level state is enough, and when a store actually earns its place.
- Error Handling at the Boundary Throw errors where they happen. Catch them where you can handle them. Keep everything in between clean.
Putting It All Together
We’ve covered the philosophy. We’ve covered the patterns. Now let’s build something.
This is a task manager. Simple enough to understand, complex enough to demonstrate real patterns. Authentication, data loading, forms, derived state, error handling—everything we’ve discussed.
Pay attention to what’s not here. No state management library. No complex abstractions. No global stores for route data. Just SvelteKit patterns applied consistently.
The Architecture
src/
├── routes/
│ ├── +layout.svelte # App shell
│ ├── +layout.server.ts # Auth check, user data
│ ├── +error.svelte # Error page
│ ├── +page.svelte # Landing/redirect
│ │
│ ├── (auth)/
│ │ ├── login/
│ │ │ ├── +page.svelte
│ │ │ └── +page.server.ts
│ │ └── register/
│ │ ├── +page.svelte
│ │ └── +page.server.ts
│ │
│ └── (app)/
│ ├── +layout.svelte # App layout (nav, sidebar)
│ ├── +layout.server.ts # Require auth
│ │
│ ├── dashboard/
│ │ ├── +page.svelte
│ │ └── +page.server.ts
│ │
│ ├── tasks/
│ │ ├── +page.svelte
│ │ └── +page.server.ts
│ │
│ └── settings/
│ ├── +page.svelte
│ └── +page.server.ts
│
├── lib/
│ ├── components/
│ │ ├── TaskCard.svelte
│ │ ├── TaskForm.svelte
│ │ └── StatsCard.svelte
│ │
│ └── server/
│ └── db.ts
│
└── app.d.ts Notice the route groups: (auth) for public pages, (app) for authenticated pages. The parentheses mean they don’t affect the URL—/login not /(auth)/login.
Authentication
Authentication lives in the root layout. Every page has access to the user.
// routes/+layout.server.ts
import { db } from '$lib/server/db'
export async function load({ cookies }) {
const sessionId = cookies.get('session')
if (!sessionId) {
return { user: null }
}
const user = await db.getUserBySession(sessionId)
return { user }
} The app layout requires authentication:
// routes/(app)/+layout.server.ts
import { redirect } from '@sveltejs/kit'
export async function load({ parent }) {
const { user } = await parent()
if (!user) {
redirect(303, '/login')
}
return { user }
} Any page under (app) is protected. The redirect happens automatically.
The Dashboard
The dashboard shows stats and recent tasks. All data comes from the load function.
// routes/(app)/dashboard/+page.server.ts
import { db } from '$lib/server/db'
export async function load({ parent }) {
const { user } = await parent()
const [stats, recentTasks] = await Promise.all([
db.getTaskStats(user.id),
db.getRecentTasks(user.id, 5)
])
return { stats, recentTasks }
} <!-- routes/(app)/dashboard/+page.svelte -->
<script>
import StatsCard from '$lib/components/StatsCard.svelte'
import TaskCard from '$lib/components/TaskCard.svelte'
let { data } = $props()
// Derived: completion percentage
let completionRate = $derived(
data.stats.total > 0 ? Math.round((data.stats.completed / data.stats.total) * 100) : 0
)
</script>
<h1>Dashboard</h1>
<div class="stats-grid">
<StatsCard label="Total Tasks" value={data.stats.total} />
<StatsCard label="Completed" value={data.stats.completed} />
<StatsCard label="Pending" value={data.stats.pending} />
<StatsCard label="Completion" value="{completionRate}%" />
</div>
<h2>Recent Tasks</h2>
{#if data.recentTasks.length === 0}
<p class="empty">No tasks yet. <a href="/tasks">Create one</a>.</p>
{:else}
<div class="task-list">
{#each data.recentTasks as task (task.id)}
<TaskCard {task} />
{/each}
</div>
{/if} Notice:
- Data from load function, not fetched in effect
- Derived values for computed stats
- Simple conditional for empty state
- Components receive data as props, not from stores
The Tasks Page
This is where CRUD happens. Load, create, update, delete—all through load functions and form actions.
// routes/(app)/tasks/+page.server.ts
import { fail } from '@sveltejs/kit'
import { db } from '$lib/server/db'
export async function load({ parent, url }) {
const { user } = await parent()
const filter = url.searchParams.get('filter') || 'all'
const tasks = await db.getTasks(user.id)
return { tasks, filter }
}
export const actions = {
create: async ({ request, locals }) => {
const formData = await request.formData()
const title = formData.get('title')
const description = formData.get('description')
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return fail(400, {
error: 'Title is required',
title,
description
})
}
await db.createTask({
userId: locals.user.id,
title: title.trim(),
description: description?.toString() || ''
})
return { success: true }
},
toggle: async ({ request, locals }) => {
const formData = await request.formData()
const taskId = formData.get('taskId')
const task = await db.getTask(taskId)
if (!task || task.userId !== locals.user.id) {
return fail(404, { error: 'Task not found' })
}
await db.updateTask(taskId, { completed: !task.completed })
return { success: true }
},
delete: async ({ request, locals }) => {
const formData = await request.formData()
const taskId = formData.get('taskId')
const task = await db.getTask(taskId)
if (!task || task.userId !== locals.user.id) {
return fail(404, { error: 'Task not found' })
}
await db.deleteTask(taskId)
return { success: true }
}
} <!-- routes/(app)/tasks/+page.svelte -->
<script>
import { enhance } from '$app/forms'
import TaskForm from '$lib/components/TaskForm.svelte'
let { data, form } = $props()
// Local UI state
let filter = $state(data.filter)
let showForm = $state(false)
// Derived: filtered tasks
let filteredTasks = $derived.by(() => {
if (filter === 'all') return data.tasks
if (filter === 'pending') return data.tasks.filter((t) => !t.completed)
if (filter === 'completed') return data.tasks.filter((t) => t.completed)
return data.tasks
})
// Derived: counts
let counts = $derived({
all: data.tasks.length,
pending: data.tasks.filter((t) => !t.completed).length,
completed: data.tasks.filter((t) => t.completed).length
})
</script>
<div class="tasks-header">
<h1>Tasks</h1>
<button onclick={() => (showForm = !showForm)}>
{showForm ? 'Cancel' : 'New Task'}
</button>
</div>
{#if showForm}
<TaskForm error={form?.error} onsubmit={() => (showForm = false)} />
{/if}
<div class="filters">
<button class:active={filter === 'all'} onclick={() => (filter = 'all')}>
All ({counts.all})
</button>
<button class:active={filter === 'pending'} onclick={() => (filter = 'pending')}>
Pending ({counts.pending})
</button>
<button class:active={filter === 'completed'} onclick={() => (filter = 'completed')}>
Completed ({counts.completed})
</button>
</div>
{#if filteredTasks.length === 0}
<p class="empty">No {filter === 'all' ? '' : filter} tasks.</p>
{:else}
<ul class="task-list">
{#each filteredTasks as task (task.id)}
<li class:completed={task.completed}>
<form method="POST" action="?/toggle" use:enhance>
<input type="hidden" name="taskId" value={task.id} />
<button type="submit" class="toggle">
{task.completed ? '✓' : '○'}
</button>
</form>
<div class="task-content">
<span class="title">{task.title}</span>
{#if task.description}
<span class="description">{task.description}</span>
{/if}
</div>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="taskId" value={task.id} />
<button type="submit" class="delete">×</button>
</form>
</li>
{/each}
</ul>
{/if} Notice:
- Local UI state for filter and form visibility (
$state) - Derived filtering happens client-side—no server round-trip for filters
- Form actions for mutations—progressive enhancement works
- Each task has its own tiny forms for toggle and delete
- No global stores—everything scoped to this route
The Task Form Component
A simple form component that works with the parent’s action:
<!-- lib/components/TaskForm.svelte -->
<script>
import { enhance } from '$app/forms'
let { error, onsubmit } = $props()
let title = $state('')
let description = $state('')
let submitting = $state(false)
</script>
<form
method="POST"
action="?/create"
use:enhance={() => {
submitting = true
return async ({ result, update }) => {
submitting = false
if (result.type === 'success') {
title = ''
description = ''
onsubmit?.()
}
await update()
}
}}
>
{#if error}
<p class="error">{error}</p>
{/if}
<label>
Title
<input name="title" bind:value={title} required disabled={submitting} />
</label>
<label>
Description (optional)
<textarea name="description" bind:value={description} disabled={submitting}></textarea>
</label>
<button type="submit" disabled={submitting}>
{submitting ? 'Creating...' : 'Create Task'}
</button>
</form> The component:
- Manages its own form state
- Uses
use:enhancefor no-reload submission - Clears itself on success
- Calls parent callback when done
Settings with Form Actions
Settings demonstrates validation and updates:
// routes/(app)/settings/+page.server.ts
import { fail } from '@sveltejs/kit'
import { db } from '$lib/server/db'
export async function load({ parent }) {
const { user } = await parent()
return { user }
}
export const actions = {
updateProfile: async ({ request, locals }) => {
const formData = await request.formData()
const name = formData.get('name')
const email = formData.get('email')
const errors: Record<string, string> = {}
if (!name || typeof name !== 'string' || name.length < 2) {
errors.name = 'Name must be at least 2 characters'
}
if (!email || typeof email !== 'string' || !email.includes('@')) {
errors.email = 'Valid email required'
}
if (Object.keys(errors).length > 0) {
return fail(400, { errors, name, email })
}
// Check email uniqueness
const existing = await db.getUserByEmail(email)
if (existing && existing.id !== locals.user.id) {
return fail(400, {
errors: { email: 'Email already in use' },
name,
email
})
}
await db.updateUser(locals.user.id, {
name: name.toString(),
email: email.toString()
})
return { success: true }
}
} <!-- routes/(app)/settings/+page.svelte -->
<script>
import { enhance } from '$app/forms'
let { data, form } = $props()
</script>
<h1>Settings</h1>
{#if form?.success}
<div class="success">Settings saved!</div>
{/if}
<form method="POST" action="?/updateProfile" use:enhance>
<label>
Name
<input
name="name"
value={form?.name ?? data.user.name}
aria-invalid={form?.errors?.name ? 'true' : undefined}
/>
{#if form?.errors?.name}
<span class="error">{form.errors.name}</span>
{/if}
</label>
<label>
Email
<input
name="email"
type="email"
value={form?.email ?? data.user.email}
aria-invalid={form?.errors?.email ? 'true' : undefined}
/>
{#if form?.errors?.email}
<span class="error">{form.errors.email}</span>
{/if}
</label>
<button type="submit">Save Changes</button>
</form> Standard pattern: server-side validation, return fail() with errors, repopulate form with submitted values.
What’s Not Here
Notice what we didn’t need:
- No state management library.
$stateand$derivedhandle everything. - No stores folder. Route data stays in routes.
- No fetch calls in components. Load functions handle data.
- No useEffect-style data loading. No
$effectfor fetching. - No prop drilling more than 2 levels. Components get what they need.
- No complex abstractions. Components are straightforward.
The total is around 500 lines of application code. You could understand any part by reading just that part. Dependencies are explicit. State is scoped. Patterns are consistent.
The Calm Checklist
Before calling a system “calm,” check:
- Can you understand a route by reading its folder?
- Does each component have obvious data sources?
- Is mutation handled through forms or explicit functions?
- Are derived values used instead of synchronized state?
- Is error handling at boundaries, not scattered?
- Would a new developer find their way?
If yes to all, you’ve built a calm system.
What’s Next?
You’ve now seen every pattern applied together. You understand:
- Why data belongs in routes, not stores
- How derived state prevents bugs
- When
$effectis (and isn’t) appropriate - How forms work with progressive enhancement
- Where errors should be handled
The goal was never to memorize patterns. It was to develop intuition for what calm code looks like. When you’re building and something feels complicated, trust that feeling. There’s probably a simpler way.
Build things. Keep them simple. Revisit these patterns when something feels off.
Welcome to calm.