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.

How load results merge into the page's data Five load sources and how each contributes to the page's data object, from layout server loads through to the page's universal load, plus the parent helper that introduces an explicit ordering dependency. How load results flow to the page source available as +layout.server.ts server-only, per layout merged into data, inherited by children +layout.ts universal, per layout merged into data +page.server.ts server-only, per page merged into data +page.ts universal, per page merged last, can read parent data await parent() the merged parent result creates a dependency — use sparingly
The last row is the only one that serialises the loads — everything else runs in parallel by default.

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.

Five triggers that re-run a SvelteKit load Five invalidation triggers: a read parameter changing, a read search parameter changing, an explicit invalidate call, invalidateAll, and the automatic revalidation after a form action. Invalidation: what re-runs a load trigger re-runs a param the load read changed that load and its children tracked automatically a searchParam the load read changed that load only if it was actually read invalidate(url) loads that depend on that url explicit dependency invalidateAll() every load the blunt instrument a form action completing loads on the page automatic after an action
Dependencies are tracked by what the load actually reads, which is why an unused parameter change re-runs nothing.

Gotchas

  • Returning a non-serialisable value (a class instance, a function, a Map) from a +page.server.ts load 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(...) and throw redirect(...) are control-flow signals, not exceptions to catch — wrapping a load body in a try/catch that 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 called depends('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.

What each kind of load may safely handle Five values compared between universal and server loads: secrets and database access require a server load, public fetches suit either, and only universal loads can return non-serialisable values such as class instances. Where a value may safely live universal load server load an API key never — the code ships yes a database query never yes a public fetch yes — reused on the client also fine a Date or Map in the result yes must be serialisable a class instance yes cannot cross the boundary
The first two rows are a security boundary; the last two are a serialisation boundary. Both are absolute.