Skip to content

Loading Data

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.

src/routes/blog/[slug]/+page.js
export async function load({ params, fetch }) {
const res = await fetch(`/api/posts/${params.slug}`);
return { post: await res.json() }; // becomes the `data` prop
}
src/routes/blog/[slug]/+page.svelte
<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"]
load runs, then the page renders with its data

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 provided fetch).
  • +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-only
import { 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 whereServer (first render), then browser (client nav)Server, always — never ships to the browser
Can touch a database / secret / private envNo — that code would ship to the browserYes
fetchThe special SvelteKit fetch (handles relative URLs, forwards cookies during SSR)Same special fetch, plus direct server-side calls
cookies, localsNot availableAvailable
Return valueMust be serializable or plain values usable directly in the component treeMust 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.

src/routes/settings/+layout.server.js
export function load({ depends }) {
depends('app:site'); // this layout's data can be re-run on demand
return { site: { name: 'Acme' } };
}
src/routes/settings/+page.server.js
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):

src/routes/settings/load.test.ts
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.

src/routes/blog/[slug]/+page.server.js
/** @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
};
}
src/routes/blog/[slug]/+page.js
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
};
}
src/routes/blog/[slug]/+page.svelte
<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.mjs
t=36ms first bytes arrived
t=36ms saw "loading comment…" in the stream (pending state)
t=2034ms saw resolved comment text in the stream
t=2035ms stream ended

The 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.html
loading comment
$ wait
curl --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.html
a slow comment, arrived late

At 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.

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.js
9: * @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.

When reviewing generated load code, check for:

  • Does it await a 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/updated from $app/stores (deprecated since Kit 2.12, proven above) instead of $app/state?
  • Does a server load return something non-serializable (a class instance, a function) instead of plain data?
  • Does a page’s own load re-fetch data the parent layout’s load already fetched, instead of reading it via parent()?
  • Is depends() called with a key that nothing ever calls invalidate() 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.

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.

What you getBenefitCost
+page.js (universal)Runs in the browser too — client nav refetches without a server round-tripCannot 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 promiseLower time-to-first-byte for the parts that matterThe 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
  • “A universal load is 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 load value must be awaited before returning.” Proven above: an unawaited promise streams — the shell renders immediately and {#await} fills in the rest.
  • “$app/stores and $app/state are two names for the same thing.” $app/stores is deprecated (proven above, straight from Kit’s own source and the migration guide) — $app/state’s page is a plain reactive object, not a store, so it’s read without $.
  • “A child page’s load has to re-fetch what the layout already loaded.” parent() returns the parent’s already-resolved data instead.
How does a SvelteKit page receive data from its load function?
When must you use +page.server.js instead of +page.js?
What does the un-awaited comment promise let the page do?
// src/routes/blog/[slug]/+page.server.js
export function load({ params }) {
  return {
    comment: loadComment(params.slug),   // returned WITHOUT awaiting
  };
}
What is a +layout.js load useful for, and how does a child page read its data?
What does calling depends('app:settings') inside a load function do?
What is true about this pattern?
<script>
  import { page } from '$app/stores';
</script>
<h1>{$page.url.pathname}</h1>