Loading Data
Data comes from a load function
Section titled “Data comes from a load function”A page doesn’t fetch its own data in the component. Instead, a load function beside the page returns the data, and SvelteKit passes it to the page as the data prop. Because load runs before the page renders (on the server for the first request), the data is present in the initial HTML — no loading flash, good SEO.
export async function load({ params, fetch }) { const res = await fetch(`/api/posts/${params.slug}`); return { post: await res.json() }; // becomes the `data` prop}<script> let { data } = $props(); // data.post is here</script>
<h1>{data.post.title}</h1>flowchart LR
req["request /blog/hello"] --> load["load({ params, fetch })"]
load --> data["return { post }"]
data --> page["+page.svelte gets data prop"]
page --> html["rendered HTML"] Universal vs server-only load
Section titled “Universal vs server-only load”There are two kinds of load, and choosing correctly is the key decision:
+page.js— universal load. Runs on the server for the first render, then in the browser for subsequent client-side navigations. Use it for data that is safe to fetch anywhere (a public API through the providedfetch).+page.server.js— server-only load. Always runs on the server, never in the browser. Use it when the code must stay server-side: direct database queries, secrets, private environment variables, reading cookies for auth. The returned data must be serializable (it’s sent to the client).
// src/routes/dashboard/+page.server.js — server-onlyimport { db } from '$lib/server/db';
export async function load({ locals }) { // locals.user was set by a hook; db + secrets never reach the browser. return { projects: await db.projectsFor(locals.user.id) };}The rule of thumb: if it touches a database, a secret, or private env, it goes in +page.server.js. Otherwise +page.js is fine and lets client navigations refetch without a server round-trip. A full comparison:
+page.js (universal) | +page.server.js (server-only) | |
|---|---|---|
| Runs where | Server (first render), then browser (client nav) | Server, always — never ships to the browser |
| Can touch a database / secret / private env | No — that code would ship to the browser | Yes |
fetch | The special SvelteKit fetch (handles relative URLs, forwards cookies during SSR) | Same special fetch, plus direct server-side calls |
cookies, locals | Not available | Available |
| Return value | Must be serializable or plain values usable directly in the component tree | Must be serializable (devalue-encoded to the client) |
Also runs for +layout | +layout.js | +layout.server.js |
The load event, depends(), and invalidation
Section titled “The load event, depends(), and invalidation”The load function receives an event object with useful fields: params (route params), fetch, url (the requested URL), setHeaders, cookies (server load only), locals (server load only), parent (to await parent layout data), and depends (to register a custom invalidation key).
Calling depends('app:posts') inside a load ties its re-run to that key. Later, calling invalidate('app:posts') (from $app/navigation) re-runs every load that called depends with that key — without a full page reload. invalidateAll() re-runs every load function on the current page, regardless of keys.
A +layout.js / +layout.server.js load provides data to its layout and every child page — ideal for things every page needs (the current user, site settings). A child page’s load can read that data with parent(), and the child’s own return value is merged on top of it for the data prop.
export function load({ depends }) { depends('app:site'); // this layout's data can be re-run on demand return { site: { name: 'Acme' } };}export async function load({ parent, depends }) { depends('app:settings'); const { site } = await parent(); // merge in the parent layout's data return { site, theme: 'dark' }; // data prop = { site, theme }}Proving depends and parent() by calling both load functions directly with a fake event, the way you’d unit-test them (no browser needed):
import { test, expect } from 'vitest';import { load as layoutLoad } from './+layout.server.js';import { load as pageLoad } from './+page.server.js';
test('+layout.server.js registers a depends key and returns site data', () => { const deps: string[] = []; const data = layoutLoad({ depends: (k: string) => deps.push(k) } as any); expect(deps).toEqual(['app:site']); expect(data).toEqual({ site: { name: 'Acme' } });});
test('+page.server.js merges the parent layout data via parent()', async () => { const deps: string[] = []; const data = await pageLoad({ depends: (k: string) => deps.push(k), parent: async () => ({ site: { name: 'Acme' } }), } as any); expect(deps).toEqual(['app:settings']); expect(data).toEqual({ site: { name: 'Acme' }, theme: 'dark' });});✓ +layout.server.js registers a depends key and returns site data✓ +page.server.js merges the parent layout data via parent()From a component, you’d call invalidate('app:settings') after some action to make just that load re-run, or invalidateAll() to refresh the whole page’s data:
<script> import { invalidate, invalidateAll } from '$app/navigation';</script>
<button onclick={() => invalidate('app:settings')}>Refresh settings only</button><button onclick={() => invalidateAll()}>Refresh everything</button>Streaming: returning a promise you don’t await
Section titled “Streaming: returning a promise you don’t await”Every example so far awaits its data before returning — the whole page waits for the slowest piece. SvelteKit can instead stream: return a promise without awaiting it, and consume it in the template with {#await}. The initial HTML ships as soon as the awaited parts are ready, and the streamed part fills in once its promise resolves — lower time-to-first-byte for the parts that matter, without blocking on the parts that don’t.
/** @param {string} slug */function loadComment(slug) { return new Promise((resolve) => { setTimeout(() => resolve(`a slow comment on ${slug}, arrived late`), 2000); });}
export function load({ params }) { return { comment: loadComment(params.slug), // NOT awaited — a streamed promise };}export async function load({ params, fetch, data }) { const res = await fetch(`/api/posts/${params.slug}`); return { post: await res.json(), comment: data.comment, // forward the server load's streamed promise unchanged };}<script> let { data } = $props(); // data.post and data.comment, merged from both load files</script>
<h1>{data.post.title}</h1>
{#await data.comment} <p>Loading comment…</p>{:then comment} <p>{comment}</p>{/await}Proving it: a Node script reads the response as a stream and timestamps every chunk against a 2-second setTimeout inside loadComment.
$ node stream-timing.mjst=36ms first bytes arrivedt=36ms saw "loading comment…" in the stream (pending state)t=2034ms saw resolved comment text in the streamt=2035ms stream endedThe same thing with plain curl --no-buffer, checking how many bytes have arrived after only 300ms:
$ ( time curl --no-buffer -s http://localhost:5196/blog/hello-world -o /tmp/out.html ) &$ sleep 0.3 && wc -c /tmp/out.html && grep -o "loading comment" /tmp/out.html 3825 /tmp/out.htmlloading comment$ waitcurl --no-buffer -s http://localhost:5196/blog/hello-world -o 0.01s user 0.01s system 0% cpu 2.019 total$ grep -o "a slow comment, arrived late" /tmp/out.htmla slow comment, arrived lateAt 300ms, 3825 bytes — including the pending loading comment… markup — have already arrived, and the whole request only finishes at 2.019s once the comment’s promise resolves. The shell of the page did not wait for the slow part.
$app/state vs the deprecated $app/stores
Section titled “$app/state vs the deprecated $app/stores”This course uses page, navigating, and updated from $app/state everywhere (as seen already in the routing lesson’s page.error). That module needs Svelte 5. Older code — and a lot of AI-generated code trained on pre-2.12 examples — imports the same things from $app/stores instead, where they’re stores you read with a $ prefix.
<!-- legacy: $app/stores (deprecated) --><script> import { page } from '$app/stores';</script><h1>{$page.url.pathname}</h1><!-- current: $app/state (Svelte 5) --><script> import { page } from '$app/state';</script><h1>{page.url.pathname}</h1>$app/stores still works — it hasn’t been removed — but the installed SvelteKit’s own source says otherwise about its future, right on the export:
$ grep -n "deprecated" node_modules/@sveltejs/kit/src/runtime/app/stores.js9: * @deprecated Use `$app/state` instead (requires Svelte 5, see docs for more info)33: * @deprecated Use `page` from `$app/state` instead (requires Svelte 5, see docs for more info)50: * @deprecated Use `navigating` from `$app/state` instead (requires Svelte 5, see docs for more info)65: * @deprecated Use `updated` from `$app/state` instead (requires Svelte 5, see docs for more info)The migration guide says it plainly: “$app/stores is deprecated and subject to be removed in SvelteKit 3.” When reading generated code that imports $page from $app/stores, recognize it as the old pattern, not a different feature.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated load code, check for:
- Does it
awaita value the page doesn’t need for its first paint (a comment thread, a “related posts” list) instead of returning the promise unawaited for{#await}to stream? - Does it import
page/navigating/updatedfrom$app/stores(deprecated since Kit 2.12, proven above) instead of$app/state? - Does a server
loadreturn something non-serializable (a class instance, a function) instead of plain data? - Does a page’s own
loadre-fetch data the parent layout’sloadalready fetched, instead of reading it viaparent()? - Is
depends()called with a key that nothing ever callsinvalidate()with — dead wiring that looks like it enables selective refresh but doesn’t?
🐛 Spot the bug
<script>import { page } from '$app/stores';
let pathname = $derived($page.url.pathname);</script>
<h1>{pathname}</h1>Show the answer
This compiles and runs today — $app/stores hasn’t been removed. But it’s the deprecated module: SvelteKit’s own source marks every export @deprecated Use \page` from `$app/state` instead, and the migration guide states $app/stores"is deprecated and subject to be removed in SvelteKit 3." The fix is to importpagefrom$app/stateinstead and drop the$prefix entirely —pagefrom$app/stateis already reactive (a$state-backed object), not a store, so page.url.pathname` is the whole expression, no subscription syntax needed.
Mental Model
Section titled “Mental Model”A load function is a data contract for a route, not a fetch call bolted onto a component. Its return value becomes the data prop, merged from the whole chain: +layout.server.js → +layout.js (via parent()) → +page.server.js → +page.js. Whether a piece of that data blocks the initial render is your choice per field: await it to guarantee it’s in the first HTML, or return the promise bare to let {#await} stream it in later. depends() and invalidate() are the same idea applied to re-runs: a load doesn’t automatically know when its data is stale, so it declares the keys it depends on, and something else decides when to invalidate them.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
+page.js (universal) | Runs in the browser too — client nav refetches without a server round-trip | Cannot touch secrets, databases, or private env |
+page.server.js (server-only) | Full server access (db, secrets, cookies) | Every client navigation still needs a server round-trip |
| Streaming an unawaited promise | Lower time-to-first-byte for the parts that matter | The reader sees a loading state; errors inside it must be handled in the {#await} (or a <svelte:boundary>) rather than failing the whole page |
depends() + invalidate() | Selective re-run instead of a full invalidateAll() | A key nobody ever invalidates is dead code that looks like it does something |
Common Misconceptions
Section titled “Common Misconceptions”- “A universal
loadis just a worse server load.” It’s the only kind that also runs in the browser — client-side navigations skip the server round-trip entirely for it, proven by the comparison table above. - “Every
loadvalue must be awaited before returning.” Proven above: an unawaited promise streams — the shell renders immediately and{#await}fills in the rest. - “
$app/storesand$app/stateare two names for the same thing.”$app/storesis deprecated (proven above, straight from Kit’s own source and the migration guide) —$app/state’spageis a plain reactive object, not a store, so it’s read without$. - “A child page’s
loadhas to re-fetch what the layout already loaded.”parent()returns the parent’s already-resolved data instead.