One Job Only
A load function has one job: get the data the page needs.
Not transform it. Not format it. Not decide how to display it. Just get it.
This sounds limiting. It’s actually freeing. When load functions are pure data fetchers, they become predictable, testable, and fast. The UI handles everything else.
What Pure Means
A pure load function:
- Takes inputs (params, url, cookies, etc.)
- Returns data
- Has no side effects beyond fetching
// routes/projects/[id]/+page.server.ts
import { db } from '$lib/db'
import { error } from '@sveltejs/kit'
export async function load({ params }) {
const project = await db.getProject(params.id)
if (!project) {
error(404, 'Project not found')
}
const tasks = await db.getProjectTasks(params.id)
return {
project,
tasks
}
} Same inputs, same outputs. No global state modified. No user-facing logic. Just data retrieval.
What Load Functions Shouldn’t Do
Don’t format data for display
// ❌ Don't do display logic in load
export async function load({ params }) {
const project = await db.getProject(params.id)
return {
project,
// Display logic doesn't belong here
formattedDate: new Intl.DateTimeFormat('en-US').format(project.createdAt),
statusBadgeColor: project.status === 'active' ? 'green' : 'gray',
taskSummary: `${project.completedTasks} of ${project.totalTasks} complete`
}
} This mixes data fetching with presentation. When display requirements change, you’re editing server code. When you need the raw date elsewhere, you don’t have it.
// ✅ Return raw data, derive display values in the UI
export async function load({ params }) {
const project = await db.getProject(params.id)
return {
project // Raw data only
}
} <!-- +page.svelte -->
<script>
let { data } = $props()
// Display logic belongs in the UI
let formattedDate = $derived(
new Intl.DateTimeFormat('en-US').format(new Date(data.project.createdAt))
)
let statusColor = $derived(data.project.status === 'active' ? 'green' : 'gray')
let taskSummary = $derived(
`${data.project.completedTasks} of ${data.project.totalTasks} complete`
)
</script> Now the load function is simple and reusable. Display logic lives where it belongs—in the component that displays things.
Don’t filter or sort based on UI state
// ❌ Don't bake UI state into load functions
export async function load({ url }) {
const status = url.searchParams.get('status') || 'all'
const sortBy = url.searchParams.get('sort') || 'date'
// Filtering and sorting in the database query
let query = db.tasks
if (status !== 'all') {
query = query.where('status', status)
}
query = query.orderBy(sortBy)
return { tasks: await query }
} This works, but it creates tight coupling between URL state and data fetching. Every filter combination hits the server. Client-side filtering becomes impossible.
For small datasets, load everything and filter in the UI:
// ✅ Load all data, filter/sort in UI
export async function load() {
const tasks = await db.getAllTasks()
return { tasks }
} <script>
let { data } = $props()
let statusFilter = $state('all')
let sortBy = $state('date')
let filteredTasks = $derived.by(() => {
let result = data.tasks
if (statusFilter !== 'all') {
result = result.filter((t) => t.status === statusFilter)
}
return result.toSorted((a, b) => {
if (sortBy === 'date') return b.createdAt - a.createdAt
if (sortBy === 'name') return a.name.localeCompare(b.name)
return 0
})
})
</script> For large datasets where you must filter server-side, use URL parameters—but recognize you’re making a tradeoff. Each filter change triggers navigation and a server round-trip.
Don’t mutate external state
// ❌ Don't mutate outside the load function
import { analytics } from '$lib/analytics'
export async function load({ params }) {
analytics.trackPageView(`/projects/${params.id}`) // Side effect!
const project = await db.getProject(params.id)
return { project }
} Load functions can run multiple times—during SSR, during hydration, during client-side navigation, during revalidation. Side effects multiply unexpectedly.
Track analytics in the component:
<script>
import { analytics } from '$lib/analytics'
let { data } = $props()
$effect(() => {
analytics.trackPageView(`/projects/${data.project.id}`)
})
</script> Effects run once per navigation on the client. Much more predictable.
What Load Functions Should Do
Fetch from multiple sources
Load functions can aggregate data from multiple sources:
export async function load({ params, locals }) {
const [project, members, activity] = await Promise.all([
db.getProject(params.id),
db.getProjectMembers(params.id),
db.getProjectActivity(params.id, { limit: 10 })
])
return {
project,
members,
activity
}
} Parallel fetching is natural. The page gets everything it needs in one load.
Handle authentication and authorization
import { error, redirect } from '@sveltejs/kit'
export async function load({ params, locals }) {
if (!locals.user) {
redirect(303, '/login')
}
const project = await db.getProject(params.id)
if (!project) {
error(404, 'Project not found')
}
const hasAccess = await db.checkProjectAccess(locals.user.id, project.id)
if (!hasAccess) {
error(403, 'You do not have access to this project')
}
return { project }
} Auth checks belong in load functions. They run before any UI renders, ensuring unauthorized users never see protected content.
Return exactly what the page needs
Be intentional about the data shape:
export async function load({ params }) {
const project = await db.getProject(params.id)
return {
project: {
id: project.id,
name: project.name,
description: project.description,
status: project.status,
createdAt: project.createdAt,
taskCount: project.tasks.length
// Don't return sensitive fields or unnecessary data
}
}
} Return what the page displays. Don’t leak internal IDs, passwords, or data the page doesn’t need.
Server vs. Universal Load Functions
SvelteKit has two types of load functions:
Server load functions (+page.server.ts) run only on the server. They can access databases, file systems, and secrets directly.
// +page.server.ts
import { db } from '$lib/server/db'
import { SECRET_API_KEY } from '$env/static/private'
export async function load() {
// Direct database access
const data = await db.query('SELECT * FROM items')
// Secret keys
const external = await fetch('https://api.example.com', {
headers: { Authorization: `Bearer ${SECRET_API_KEY}` }
})
return { data }
} Universal load functions (+page.ts) run on both server and client. They’re useful for fetching from public APIs.
// +page.ts
export async function load({ fetch }) {
// Use the provided fetch - it works on both server and client
const response = await fetch('https://api.example.com/public/data')
const data = await response.json()
return { data }
} Default to server load functions. They’re more secure (secrets never reach the client) and more flexible (direct database access). Use universal load functions only when you need client-side revalidation without a server round-trip.
The Purity Payoff
Pure load functions are:
Testable. Mock the database, call the function, assert on the output. No component rendering needed.
// test
const result = await load({
params: { id: '123' },
locals: { user: mockUser }
})
expect(result.project.id).toBe('123') Predictable. Same inputs, same outputs. No wondering what global state affects the result.
Fast. No UI logic means less code to run on the server. Data returns faster.
Cacheable. Pure functions with the same inputs can return cached results. SvelteKit handles this automatically in many cases.
Let load functions load. Let the UI present. Keep them separate, and both become simpler.
Next up: Forms are how users change data. SvelteKit’s form actions handle submissions with progressive enhancement built in. We’ll see how to build forms that work even without JavaScript.