SvelteKit Load Functions: Server vs Universal Data Loading
Introduction
Every web application needs to load data. Whether you are fetching blog posts from a CMS, user profiles from a database, or product listings from an API, SvelteKit provides a structured way to load data before your page renders.
SvelteKit offers two types of load functions: server load functions (+page.server.ts) and universal load functions (+page.ts). Understanding when to use each one is crucial for building performant, secure applications.
How Load Functions Work
Load functions run before your page component renders. They fetch data and return it as props to the page. Here is the simplest example:
// src/routes/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {
message: 'Hello from the server!'
};
};
<!-- src/routes/+page.svelte -->
<script lang="ts">
let { data } = $props();
</script>
<h1>{data.message}</h1>
The data prop in your page component contains whatever the load function returns.
Server Load Functions (+page.server.ts)
Server load functions run exclusively on the server. They are never sent to the browser, which makes them ideal for sensitive operations.
When to Use Server Load
Use +page.server.ts when you need to:
- Query a database directly
- Use API keys or secrets
- Access the filesystem
- Use server-only npm packages
- Read cookies or set headers
Database Access
// src/routes/posts/+page.server.ts
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/database';
export const load: PageServerLoad = async () => {
const posts = await db.query(`
SELECT id, title, slug, excerpt, published_at
FROM posts
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 20
`);
return { posts };
};
The $lib/server/ directory is special in SvelteKit. Files inside it can only be imported by server-side code. If you accidentally try to import a server module in a universal load function or a component, SvelteKit will throw an error at build time.
Accessing Cookies and Headers
Server load functions receive a rich event object:
// src/routes/dashboard/+page.server.ts
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, request }) => {
const sessionToken = cookies.get('session');
if (!sessionToken) {
redirect(303, '/login');
}
const user = await getUserFromSession(sessionToken);
if (!user) {
cookies.delete('session', { path: '/' });
redirect(303, '/login');
}
return {
user: {
id: user.id,
name: user.name,
email: user.email
}
};
};
Setting Headers and Cookies
You can set response headers and cookies from server load functions:
export const load: PageServerLoad = async ({ cookies, setHeaders }) => {
// Set cache headers
setHeaders({
'cache-control': 'public, max-age=60'
});
// Set a cookie
cookies.set('visited', 'true', {
path: '/',
maxAge: 60 * 60 * 24 * 365
});
return { data: 'some data' };
};
Universal Load Functions (+page.ts)
Universal load functions run on both the server (during SSR) and the client (during client-side navigation). This dual execution is what makes them "universal."
When to Use Universal Load
Use +page.ts when:
- You are calling a public API that does not require secrets
- You want client-side navigation to skip a server round-trip
- You need to return non-serializable data (component constructors, functions)
Calling Public APIs
// src/routes/weather/+page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ fetch }) => {
const response = await fetch('https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t_weather=true');
const weather = await response.json();
return { weather };
};
The Special fetch
Notice that we use the fetch provided by SvelteKit (from the function parameter), not the global fetch. This special fetch has important benefits:
- During SSR, it can make requests to your own API routes without an HTTP round-trip
- It forwards cookies and headers from the incoming request
- It makes relative URLs work on the server
- It prevents duplicate requests during hydration
Always use the SvelteKit-provided fetch in load functions:
// GOOD
export const load: PageLoad = async ({ fetch }) => {
const res = await fetch('/api/data');
return { data: await res.json() };
};
// BAD - don't use global fetch
export const load: PageLoad = async () => {
const res = await globalThis.fetch('/api/data');
return { data: await res.json() };
};
Returning Non-Serializable Data
One unique advantage of universal load functions is that they can return non-serializable data like component constructors and functions:
// src/routes/[type]/+page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
let component;
switch (params.type) {
case 'chart':
component = (await import('$lib/components/Chart.svelte')).default;
break;
case 'table':
component = (await import('$lib/components/Table.svelte')).default;
break;
default:
component = (await import('$lib/components/Default.svelte')).default;
}
return { component };
};
Server load functions cannot do this because data must be serialized when sent from server to client.
Layout Load Functions
Layouts can also have load functions. Data from layout loads is available to all child pages:
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
const theme = cookies.get('theme') || 'light';
return { theme };
};
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
let { data, children }: { data: any; children: Snippet } = $props();
</script>
<div class="app" data-theme={data.theme}>
{@render children()}
</div>
Every page under this layout has access to data.theme without needing to load it themselves.
Accessing Parent Data
Child load functions can access data from parent layouts using the parent() function:
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ parent }) => {
const parentData = await parent();
// parentData contains data from layout load functions
const userId = parentData.user?.id;
const projects = await getProjectsForUser(userId);
return { projects };
};
Be careful with parent() — it creates a dependency waterfall. The child load waits for all parent loads to complete. Only use it when you genuinely need parent data.
Data Flow and Waterfalls
Understanding how data flows through layouts and pages is important for performance.
Parallel Loading
By default, sibling load functions run in parallel:
+layout.server.ts ──→ Returns layout data
+page.server.ts ──→ Returns page data
Both start simultaneously. The page renders when both complete.
Avoiding Waterfalls
A waterfall occurs when one load function depends on another's result:
// src/routes/+layout.server.ts
export const load: LayoutServerLoad = async () => {
const user = await getUser(); // Takes 200ms
return { user };
};
// src/routes/dashboard/+page.server.ts
export const load: PageServerLoad = async ({ parent }) => {
const { user } = await parent(); // Waits for layout (200ms)
const projects = await getProjects(user.id); // Then takes 300ms
return { projects };
// Total: 500ms (sequential)
};
To avoid the waterfall, restructure so the page load does not depend on parent:
// src/routes/dashboard/+page.server.ts
export const load: PageServerLoad = async ({ locals }) => {
// Access user from locals (set in hooks) instead of parent
const projects = await getProjects(locals.user.id);
return { projects };
// Now layout and page load in parallel!
};
Invalidation and Reloading
SvelteKit provides tools to reload data when something changes.
invalidate()
Call invalidate() to re-run load functions that depend on a specific URL:
<script lang="ts">
import { invalidate } from '$app/navigation';
let { data } = $props();
async function refresh() {
// Re-runs any load function that uses fetch('/api/posts')
await invalidate('/api/posts');
}
async function refreshAll() {
// Re-runs ALL load functions for the current page
await invalidate(() => true);
}
</script>
<button onclick={refresh}>Refresh Posts</button>
depends()
Mark a load function as depending on a custom identifier:
export const load: PageLoad = async ({ depends, fetch }) => {
depends('app:posts');
const res = await fetch('/api/posts');
return { posts: await res.json() };
};
<script lang="ts">
import { invalidate } from '$app/navigation';
async function refresh() {
await invalidate('app:posts');
}
</script>
invalidateAll()
The nuclear option — re-runs every load function on the current page:
<script lang="ts">
import { invalidateAll } from '$app/navigation';
</script>
<button onclick={() => invalidateAll()}>Refresh Everything</button>
Streaming with Promises
SvelteKit supports streaming data by returning promises from server load functions. The page renders immediately with the available data, and streamed data fills in as it resolves:
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
// This data is available immediately
const quickData = await getQuickData();
return {
quickData,
// This promise streams in later
slowData: getSlowData(), // Note: no await!
analytics: getAnalytics() // Also streams
};
};
In your page, use {#await} blocks to handle the streaming:
<script lang="ts">
let { data } = $props();
</script>
<!-- Available immediately -->
<h1>Dashboard</h1>
<p>Welcome, {data.quickData.userName}!</p>
<!-- Streams in -->
{#await data.slowData}
<div class="skeleton">Loading recent activity...</div>
{:then activity}
<ul>
{#each activity as item}
<li>{item.description}</li>
{/each}
</ul>
{:catch error}
<p class="error">Failed to load activity: {error.message}</p>
{/await}
{#await data.analytics}
<div class="skeleton">Loading analytics...</div>
{:then stats}
<div class="stats-grid">
<div>Visitors: {stats.visitors}</div>
<div>Page Views: {stats.pageViews}</div>
</div>
{:catch error}
<p class="error">Failed to load analytics</p>
{/await}
Streaming gives users something to see immediately while slower data loads in the background. The page does not block on the slowest query.
Comparison Table
Here is a side-by-side comparison to help you decide:
| Feature | +page.server.ts | +page.ts |
|---|---|---|
| Runs on server | Yes | Yes (SSR) |
| Runs on client | No | Yes (navigation) |
| Access database | Yes | No |
| Use secrets/API keys | Yes | No |
| Access cookies | Yes | No |
| Return functions | No | Yes |
| Client-side navigation | Server round-trip | No round-trip |
| Code sent to browser | No | Yes |
Rule of Thumb
- Default to
+page.server.tsfor most data loading. It is more secure and keeps sensitive code off the client. - Use
+page.tswhen you need client-side navigation performance, or when you need to return non-serializable data. - Combine both when needed — server load runs first, and universal load can access its data.
Summary
SvelteKit's data loading system is flexible and well-designed:
- Server load functions (
+page.server.ts) run only on the server, ideal for databases and secrets - Universal load functions (
+page.ts) run on both server and client, ideal for public APIs - Layout loads provide shared data to all child routes
parent()accesses parent layout data (use sparingly to avoid waterfalls)invalidate()anddepends()give fine-grained control over reloading- Streaming with promises shows fast data immediately while slow data loads in the background
Mastering load functions means your SvelteKit applications will be fast, secure, and well-structured. Start with server load functions for safety, optimize with universal loads where needed, and use streaming to deliver the best possible user experience.
Next Steps
- Learn about SvelteKit routing to structure your app
- Explore form actions for handling user input
- Master Svelte 5 runes for state management
Source: Teta Engineering
This content was generated with AI from public sources. It represents analysis and commentary, not original journalism.