SvelteKit Load Functions and Data Routing
After reading this you will be able to choose correctly between a universal +page.ts and a server-only +page.server.ts load function, layer data with +layout loads, type both with PageLoad/PageServerLoad, and trigger a precise re-run with invalidate instead of a full reload.
← Back to SvelteKit Routing Conventions
Prerequisites
Core Concept
In SvelteKit a route’s data comes from a load function, and where that function may run is the single most important decision you make. A load exported from +page.ts (or +layout.ts) is universal: it runs on the server during SSR and then again in the browser during client-side navigation, so it must contain nothing secret and nothing Node-only. A load exported from +page.server.ts (or +layout.server.ts) is server-only: it always runs on the server, may touch databases, environment secrets, and private modules, and ships its serialised return value to the client as JSON. Both receive an event object exposing params, url, fetch, parent, and depends, and both feed the data prop of the matching +page.svelte. Layout loads run for every child route and their results merge down the tree, so a +layout.server.ts that loads the session runs once and every nested page reads it via parent(). The framework tracks which URLs and dependencies each load touched, so when something changes you call invalidate to re-run only the affected loads rather than reloading the document.
Implementation
The pair below shows the two load kinds cooperating. The server load reads a session cookie and fetches private data; the universal load enriches it with a public request and registers a custom dependency so a later invalidate('app:cart') re-runs just this function. The ./$types imports are generated by SvelteKit per route.
// SvelteKit 2.x — src/routes/products/[id]/+page.server.ts
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
// Server-only: runs on the server for SSR and for client navigations via a
// fetch to SvelteKit's internal data endpoint. Safe to read secrets here.
export const load: PageServerLoad = async ({ params, cookies, fetch }) => {
const session = cookies.get("session");
const res = await fetch(`https://internal.api/products/${params.id}`, {
headers: session ? { authorization: `Bearer ${session}` } : {},
});
if (res.status === 404) throw error(404, "Product not found");
const product = (await res.json()) as { id: string; name: string };
// The returned object is serialised to the client — plain JSON only.
return { product };
};
// SvelteKit 2.x — src/routes/products/[id]/+page.ts
import type { PageLoad } from "./$types";
// Universal: runs on server (SSR) AND in the browser (client nav). No secrets.
export const load: PageLoad = async ({ params, fetch, parent, depends }) => {
// Merge in whatever the server load and parent layouts already produced.
const { product } = await parent();
// Register a custom invalidation key so invalidate('app:cart') re-runs
// only this load, not the whole route tree.
depends("app:cart");
// event.fetch is instrumented: during SSR it inlines the response into the
// HTML so the browser does not refetch it on hydration.
const stockRes = await fetch(`/api/products/${params.id}/stock`);
const stock = (await stockRes.json()) as { available: number };
return { product, stock };
};
The +page.svelte reads the merged result through its typed data prop, and a control can request a fresh load without a document reload:
// SvelteKit 2.x — inside src/routes/products/[id]/+page.svelte <script lang="ts">
import { invalidate } from "$app/navigation";
import type { PageData } from "./$types";
export let data: PageData; // { product, stock } — fully typed from the loads
async function refreshCart(): Promise<void> {
// Re-runs only loads that called depends("app:cart"); pass a URL string
// instead to re-run loads whose fetch touched that URL.
await invalidate("app:cart");
}
Because the universal load calls parent(), SvelteKit runs the server load first, waits for it, then runs the universal load — but sibling loads with no parent() dependency run concurrently, so avoid an unnecessary await parent() when you do not consume its result.
Verification
Confirm two things that distinguish correct data routing: server-only code never reaches the browser, and invalidate re-runs the right load without a full navigation.
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("server load data renders but its secret path is not in the client bundle", async ({ page }) => {
await page.goto("/products/42");
await expect(page.getByRole("heading", { name: /./ })).toBeVisible();
// The private API host from +page.server.ts must not leak into shipped JS.
const body = await page.content();
expect(body).not.toContain("internal.api");
});
test("invalidate refreshes data without a document reload", async ({ page }) => {
await page.goto("/products/42");
const before = page.url();
await page.getByRole("button", { name: /refresh/i }).click();
// URL is unchanged and no full navigation occurred; data prop updated.
expect(page.url()).toBe(before);
});
For a manual check, add a console.log at the top of each load. Load the page fresh: the server load logs on the server terminal only, the universal load logs in both the terminal (SSR) and the browser console (hydration). Then navigate client-side to a sibling and back to confirm the universal load re-runs while the server load is fetched over the data endpoint.
Gotchas
- Returning a non-serialisable value (a class instance, a function, a
Map) from a+page.server.tsload throws, because the result must cross the server-client boundary as JSON; universal loads in the browser may return richer values since they never serialise. throw error(...)andthrow redirect(...)are control-flow signals, not exceptions to catch — wrapping a load body in atry/catchthat swallows everything will accidentally intercept them and break error pages and redirects.- Calling
await parent()creates a sequential dependency on the parent load; sprinkle it in unnecessarily and you serialise loads that could have run in parallel, adding latency to every navigation. invalidate('some string')only re-runs loads that either calleddepends('some string')or fetched that exact URL — a mismatched key silently refreshes nothing, so keep the dependency string and the invalidation string identical.
FAQ
What is the difference between +page.ts and +page.server.ts load functions? A +page.ts load is universal and runs on both server and client, so it must be free of secrets and Node-only APIs. A +page.server.ts load runs only on the server, can safely read databases and environment secrets, and serialises its return value to the client as JSON.
When should I use a layout load instead of a page load? Use a layout load for data that every child route needs, such as the current user or navigation menu. It runs once for the whole subtree and each page reads the merged result through parent(), avoiding duplicate fetches on every nested page.
Why is my server load data undefined in the browser? Most often the return value was not serialisable — a class instance, function, or Map cannot cross the server-client boundary. Return plain JSON-compatible objects from server loads, and move any rich client-only values into a universal load instead.
How does invalidate differ from a full page reload? invalidate re-runs only the load functions that depend on the given key or URL and updates the data prop in place, with no document reload and no scroll reset. A full reload re-fetches the HTML and reruns every load from scratch, losing client state.
Can a universal load call fetch to my own API safely during SSR? Yes — use the event.fetch passed into load rather than the global fetch. It forwards cookies, resolves relative URLs, and during SSR inlines the response into the rendered HTML so the browser does not repeat the request on hydration.
Related
- SvelteKit Routing Conventions — the parent guide to SvelteKit’s file-based routes, layouts, and directory conventions.
- Nested Routes in Vue Router 4 — how another framework composes parent and child routes for comparison.
- SPA Shell Caching vs Full SSR — the rendering trade-off behind SvelteKit’s universal-versus-server load split.