Routes as Architecture

In most applications, routing is an afterthought. You build components, wire up some state, and eventually add a router to show different views.

SvelteKit inverts this. Routes come first. They’re not just URLs—they’re boundaries that define where state lives and who owns it.

This is liberating once you embrace it. Each route is a self-contained unit. You can understand it by reading the files in its folder. You can delete it without breaking distant parts of the app. You can hand it to another developer without explaining the whole codebase.

The File System Is Your Architecture

In SvelteKit, your file structure is your application architecture:

src/routes/
├── +page.svelte           # Home page
├── +layout.svelte         # Root layout
├── dashboard/
│   ├── +page.svelte       # Dashboard page
│   ├── +page.server.ts    # Dashboard data
│   └── +layout.svelte     # Dashboard layout
├── projects/
│   ├── +page.svelte       # Project list
│   ├── +page.server.ts    # Load projects
│   └── [id]/
│       ├── +page.svelte   # Single project
│       └── +page.server.ts # Load single project
└── settings/
    ├── +page.svelte       # Settings page
    └── +page.server.ts    # Settings data & actions

Look at that structure. Without reading any code, you know:

  • There’s a dashboard with its own layout
  • Projects have a list view and detail views
  • Settings is a standalone page

The file system documents the application. No architecture diagrams needed.

Each Route Owns Its Data

The most important principle: data belongs to routes, not components.

A route’s +page.server.ts (or +page.ts) loads the data that route needs. The +page.svelte receives that data and renders it. This separation is enforced by the file system.

// routes/dashboard/+page.server.ts
import { db } from '$lib/db'

export async function load({ locals }) {
	const stats = await db.getDashboardStats(locals.user.id)
	const recentActivity = await db.getRecentActivity(locals.user.id)

	return {
		stats,
		recentActivity
	}
}
<!-- routes/dashboard/+page.svelte -->
<script>
	let { data } = $props()

	// Derive view-specific state from the loaded data
	let totalTasks = $derived(data.stats.completed + data.stats.pending)
	let completionRate = $derived(
		totalTasks > 0 ? ((data.stats.completed / totalTasks) * 100).toFixed(1) : 0
	)
</script>

<h1>Dashboard</h1>

<div class="stats">
	<div class="stat">
		<span class="value">{data.stats.completed}</span>
		<span class="label">Completed</span>
	</div>
	<div class="stat">
		<span class="value">{data.stats.pending}</span>
		<span class="label">Pending</span>
	</div>
	<div class="stat">
		<span class="value">{completionRate}%</span>
		<span class="label">Completion Rate</span>
	</div>
</div>

<h2>Recent Activity</h2>
<ul>
	{#each data.recentActivity as activity}
		<li>{activity.description} - {activity.timestamp}</li>
	{/each}
</ul>

The dashboard owns its data. It doesn’t reach into a global store. It doesn’t call an API directly. It receives what it needs from its load function.

Layouts Share Down, Not Up

Layouts wrap pages and other layouts. They can load data and pass it down through the hierarchy.

routes/
├── +layout.svelte         # App shell, nav
├── +layout.server.ts      # Load user (available everywhere)
└── dashboard/
    ├── +layout.svelte     # Dashboard-specific layout
    ├── +layout.server.ts  # Load dashboard-wide data
    ├── +page.svelte       # Dashboard home
    └── analytics/
        └── +page.svelte   # Dashboard analytics

Data flows down:

// routes/+layout.server.ts
export async function load({ locals }) {
	return {
		user: locals.user // Available to ALL pages
	}
}
// routes/dashboard/+layout.server.ts
export async function load({ parent }) {
	const { user } = await parent() // Get data from parent layout

	return {
		teams: await db.getTeams(user.id) // Available to dashboard pages
	}
}
<!-- routes/dashboard/analytics/+page.svelte -->
<script>
	let { data } = $props()

	// Has access to: data.user (from root), data.teams (from dashboard)
</script>

<h1>Analytics for {data.user.name}</h1>

<select>
	{#each data.teams as team}
		<option value={team.id}>{team.name}</option>
	{/each}
</select>

Parent layouts load shared data. Child pages receive it. Data flows one direction: down.

What layouts don’t do is receive data from pages. If a page needs to communicate up, it uses navigation, form actions, or URL state—not shared mutable state.

Why This Matters

Route-scoped state prevents a category of bugs that plague larger applications.

No stale data across routes. When you navigate to /projects/123, that route loads project 123. When you navigate to /projects/456, it loads project 456. There’s no “did I remember to clear the previous project?” question. Each navigation is a fresh start.

No mysterious dependencies. If the dashboard breaks, you look in routes/dashboard/. The data comes from +page.server.ts. The UI is in +page.svelte. The bug is in one of those files, not in some store imported from somewhere else.

Easy deletion. Want to remove the analytics feature? Delete routes/dashboard/analytics/. Done. No hunting for store references. No “is anything else using this data?” worry.

Natural code splitting. Each route is its own chunk. Users only download the code for routes they visit. This happens automatically—no configuration.

When State Should Cross Routes

Not all state is route-scoped. Some things genuinely need to be global:

User authentication. The current user is needed everywhere. Load it in the root layout.

Theme/preferences. UI settings that affect every page. Root layout or a small store.

Feature flags. Global configuration. Root layout.

Toast notifications. UI feedback that persists across navigation. Small store.

The pattern: if state is needed by every route, it goes in the root layout. If it’s needed by some routes, it goes in a parent layout. If it’s needed by one route, it stays in that route.

routes/
├── +layout.server.ts      # User, theme, feature flags (global)
├── +layout.svelte         # Toast container (global UI)
└── admin/
    ├── +layout.server.ts  # Admin permissions (admin routes only)
    └── users/
        └── +page.server.ts # User list (this route only)

The Temptation to Over-Share

The most common mistake is putting route-specific data in global stores “for convenience.”

// ❌ Don't do this
// lib/stores/project.js
export const currentProject = writable(null)
<!-- routes/projects/[id]/+page.svelte -->
<script>
	import { currentProject } from '$lib/stores/project'

	// Load project and put it in the store
	$effect(() => {
		fetch(`/api/projects/${$page.params.id}`)
			.then((r) => r.json())
			.then((p) => currentProject.set(p))
	})
</script>

This creates problems:

  • Stale data when navigating between projects
  • Race conditions if navigation happens quickly
  • Unclear ownership—who sets this? Who clears it?
  • Testing becomes harder—you need to set up the store

The fix is simple: let the route own its data.

// ✅ Do this
// routes/projects/[id]/+page.server.ts
export async function load({ params }) {
	const project = await db.getProject(params.id)
	return { project }
}
<!-- routes/projects/[id]/+page.svelte -->
<script>
	let { data } = $props()
</script>

<h1>{data.project.name}</h1>

No store. No global state. No synchronization bugs. The route loads what it needs, renders it, and that’s it.

The Architecture Payoff

After six months, a route-scoped codebase looks like this:

routes/
├── (marketing)/
│   ├── +page.svelte          # Landing page
│   ├── pricing/+page.svelte  # Pricing page
│   └── about/+page.svelte    # About page
├── (app)/
│   ├── +layout.svelte        # App shell
│   ├── +layout.server.ts     # Auth check
│   ├── dashboard/
│   ├── projects/
│   ├── team/
│   └── settings/
└── (auth)/
    ├── login/
    └── register/

You can point to any folder and know exactly what it does. New developers navigate by URL, not by grep. Features are added by creating folders, removed by deleting them.

This is what calm architecture looks like: the file system tells the story.


Next up: Routes define boundaries. Load functions fill them with data. We’ll look at how to write load functions that are pure, predictable, and let the UI do its job.