Universal Data Loading in SvelteKit

Static pages are useful, but real applications need data. The +page.js file (or +page.ts for TypeScript) is where you load data for your pages. This is called universal data loading because the same code runs on both the server and the browser — and understanding why this matters is the key to using it well.

When a user visits your app for the first time (typing a URL or hitting refresh), the page is rendered on the server. At that point, Node.js executes your load function to fetch data before sending HTML to the browser. But when the user then clicks a link to another page, there is no server round-trip — SvelteKit runs the same load function directly in the browser. One function, two environments, consistent behavior. That’s the contract +page.js offers.

The constraint that comes with this power: your code must work in both environments. No window, no document, no direct database access, no private API keys. If you need any of those, use +page.server.js instead.

Universal vs Server Load

The “universal” nature of +page.js is both its strength and its constraint. It enables instant client-side navigation (no server round-trip needed on subsequent page visits), but it means you can only use code that works in both Node.js and the browser. Choose wisely based on whether your data is public or sensitive.


Your First Load Function

Let’s create a blog page that displays a list of posts. First, create a +page.ts file inside the src/routes/blog/ directory:

// src/routes/blog/+page.ts
import type { PageLoad } from './$types'

export const load: PageLoad = async () => {
	// In a real app, this might come from an API or database
	const posts = [
		{
			slug: 'hello-world',
			title: 'Hello World',
			excerpt: 'My first blog post ever!',
			date: '2025-01-15'
		},
		{
			slug: 'learning-svelte',
			title: 'Learning Svelte',
			excerpt: 'Why Svelte is my new favorite framework.',
			date: '2025-01-14'
		},
		{
			slug: 'routing-deep-dive',
			title: 'SvelteKit Routing Deep Dive',
			excerpt: 'Everything you need to know about routing.',
			date: '2025-01-13'
		}
	]

	return {
		posts
	}
}

This load function runs whenever a user visits the /blog page. It fetches a list of blog posts (hardcoded here for clarity) and returns them as an object. Whatever you return becomes available as data in the corresponding +page.svelte — there is no extra wiring required; SvelteKit connects them automatically.


Connecting Data to the Page

Whatever object you return from your load function becomes available in your page component as the data prop.

<!-- src/routes/blog/+page.svelte -->
<script lang="ts">
	import type { PageProps } from './$types'

	// The $props() rune receives the data from the load function
	let { data }: PageProps = $props()
</script>

<h1>Blog</h1>

<ul class="post-list">
	{#each data.posts as post}
		<li>
			<article>
				<h2>
					<a href="/blog/{post.slug}">{post.title}</a>
				</h2>
				<time datetime={post.date}>{post.date}</time>
				<p>{post.excerpt}</p>
			</article>
		</li>
	{/each}
</ul>

<style>
	.post-list {
		list-style: none;
		padding: 0;
	}

	article {
		margin-bottom: 2rem;
		padding-bottom: 2rem;
		border-bottom: 1px solid #eee;
	}

	h2 {
		margin-bottom: 0.5rem;
	}

	time {
		color: #666;
		font-size: 0.875rem;
	}
</style>

How Data Flows: The Mental Model

Understanding this data flow is the key to mastering SvelteKit. It’s a predictable, one-way street, and the important thing to internalise is that the component never waits for data — by the time your component runs, the data is already there.

  1. The Trigger: A user visits a page (e.g., /blog)
  2. The Loader: SvelteKit looks for a +page.js file and runs the load function
  3. The Wait: SvelteKit waits for your load function to finish (including any await calls)
  4. The Handoff: The object returned by load is passed to the page component
  5. The Render: The +page.svelte component renders, receiving the data via the data prop
flowchart
    A(User visits /blog) --> B[+page.ts - load function runs]
    B --> C{Fetch & Await}
    C --> G(Data Ready)
    G --> D(Return Data Object)
    D --> H(Pass to Component)
    H --> E(Render +page.svelte)
    E --> F([Display data.posts])

    style A fill:#ffffff, color:#000
    style F fill:#e8f5e9, color:#000
    style B fill:#fff3e0, color:#000
    style E fill:#f3e5f5, color:#000
	style C fill:#ffebee, color:#000
	style G fill:#a4fcb1, color:#000
	style D fill:#f9fbe7, color:#000
	style H fill:#e1f5fe, color:#000

Crucial Concept

The load function runs before your component renders. This means you don’t need to handle “loading states” inside your component for initial data — no isLoading = true flags, no conditional renders checking whether data exists. By the time +page.svelte mounts, the data is guaranteed to be there. This is fundamentally different from SPAs where components fetch data themselves after mounting.


Fetching Data from APIs

In real applications, you’ll fetch data from APIs. The SvelteKit load function receives a special custom fetch function that works on both server and browser:

// src/routes/blog/+page.ts
import type { PageLoad } from './$types'

export const load: PageLoad = async ({ fetch }) => {
	// This fetch works on server AND browser
	const response = await fetch('/api/posts')
	const posts = await response.json()

	return {
		posts
	}
}

Why Use the Provided fetch?

This is one of SvelteKit’s most important subtleties. If you use the global fetch directly, you lose several guarantees that only the provided version offers.

During SSR, the provided fetch can call your own +server.js endpoints as if it were a client — it knows about your app’s internal routing. The global fetch in Node.js would need an absolute URL and wouldn’t know which port your app is running on.

  • Handles internal routes: Can call your own API endpoints during SSR without needing absolute URLs
  • Deduplicates requests: If multiple load functions fetch the same URL, only one HTTP request fires
  • Preserves cookies: Forwards the user’s cookies automatically for authenticated requests
  • Works everywhere: Same code runs on server and client without modification

Always destructure fetch from the load function parameter rather than using the global one.


The Load Function Parameters

The load function receives an object with several useful properties:

export const load: PageLoad = async ({
	fetch, // Special fetch that works server + client
	params, // Route parameters (e.g., { slug: 'my-post' })
	url, // URL object with pathname, searchParams, etc.
	route, // Information about the current route
	data // Data from parent layouts (if they exist)
}) => {
	// Your loading logic here
}

You can destructure only the properties you need:

export const load: PageLoad = async ({ params, url }) => {
	// Use what you need
}

Using Route Parameters

Route parameters are dynamic segments in your URL, defined by square brackets in your file or folder names (e.g., [slug], [id]). When a user visits a URL that matches a dynamic route, SvelteKit extracts the value and makes it available in the params object of your load function.

For dynamic routes like /blog/[slug], you can access the parameter:

// src/routes/blog/[slug]/+page.ts
import type { PageLoad } from './$types'

export const load: PageLoad = async ({ params, fetch }) => {
	// params.slug contains the value from the URL
	const response = await fetch(`/api/posts/${params.slug}`)
	const post = await response.json()

	return {
		post
	}
}

For more complex routing needs and dynamic parameters, see our Advanced Routing guide.

Using URL Search Parameters

In Svelte 5, the url parameter provided to your load function is a URL object, so you can use url.searchParams.get('key') directly. This makes it easy to read query strings for filtering, searching, or pagination.

Access query strings like /search?q=svelte:

// src/routes/search/+page.ts
import type { PageLoad } from './$types'

export const load: PageLoad = async ({ url, fetch }) => {
	const query = url.searchParams.get('q') || ''

	const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
	const results = await response.json()

	return {
		query,
		results
	}
}

When Does load Run?

This is important to understand:

ScenarioWhere load Runs
First page visit (typing URL, refresh)Server
Clicking a link to this pageBrowser
Using browser back/forwardBrowser (usually cached)

The same load function works in both environments. This is powerful, but it means you cannot use server-only features here (like direct database access or private API keys). For those cases, use +page.server.js.


Returning Different Data Types

The load function can return any serializable data. This flexibility allows you to:

  • Return data from multiple sources
  • Include metadata for the page
  • Pass derived/calculated values
  • Provide component configuration
export const load: PageLoad = async ({ fetch }) => {
	const [postsRes, categoriesRes] = await Promise.all([
		fetch('/api/posts'),
		fetch('/api/categories')
	])

	return {
		posts: await postsRes.json(),
		categories: await categoriesRes.json(),
		metadata: {
			title: 'Blog',
			description: 'Read our latest posts'
		}
	}
}

Parallel Data Loading

When your page needs data from several independent sources, fetching them in parallel means all requests start at the same time. This dramatically reduces total loading time — the page loads in the time of the slowest request, not the sum of all requests.

Here is the key distinction to understand: await inside async code is sequential. Each await pauses execution until that promise resolves before moving on. Promise.all, by contrast, fires all promises simultaneously and waits for all of them together.

// PREFERRED: All three requests fire simultaneously
export const load: PageLoad = async ({ fetch }) => {
	const [posts, categories, featured] = await Promise.all([
		fetch('/api/posts').then((r) => r.json()),
		fetch('/api/categories').then((r) => r.json()),
		fetch('/api/featured').then((r) => r.json())
	])

	return { posts, categories, featured }
}

Avoid sequential fetches when requests are independent. This pattern adds latency proportional to the number of requests:

// AVOID: Sequential — each request waits for the previous to complete
// If each call takes 100ms, this takes ~300ms total.
// The Promise.all above takes ~100ms regardless of request count.
export const load: PageLoad = async ({ fetch }) => {
	const posts = await fetch('/api/posts').then((r) => r.json())
	const categories = await fetch('/api/categories').then((r) => r.json())
	const featured = await fetch('/api/featured').then((r) => r.json())

	return { posts, categories, featured }
}

The sequential pattern is only appropriate when one request genuinely depends on the result of another — for example, fetching a user’s ID and then fetching that user’s posts. If the requests are independent, always use Promise.all.

Performance Tip

Use Promise.all for independent data sources. Your page will load as fast as the slowest request, not the sum of all requests.


Error Handling in Load Functions

Proper error handling is essential for good user experience. Use the error helper to trigger SvelteKit’s error handling mechanism:

import { error } from '@sveltejs/kit'
import type { PageLoad } from './$types'

export const load: PageLoad = async ({ params, fetch }) => {
	const response = await fetch(`/api/posts/${params.slug}`)

	if (response.status === 404) {
		error(404, {
			message: 'Post not found',
			hint: 'The post may have been removed or renamed.'
		})
	}

	if (!response.ok) {
		error(response.status, 'Failed to load post')
	}

	return {
		post: await response.json()
	}
}

For a deep dive into custom error pages and advanced error handling, see +error.svelte.


Common Mistakes

#1: Using Browser APIs

// AVOID:  localStorage doesn't exist on the server!
export const load: PageLoad = async () => {
	const theme = localStorage.getItem('theme')
	return { theme }
}

// PREFERRED: Check for browser environment
import { browser } from '$app/environment'

export const load: PageLoad = async () => {
	const theme = browser ? localStorage.getItem('theme') : 'light'
	return { theme }
}

#2: Not Handling Errors

// AVOID:  What if the API is down?
export const load: PageLoad = async ({ fetch }) => {
	const response = await fetch('/api/data')
	return await response.json()
}

// PREFERRED: Handle error cases
import { error } from '@sveltejs/kit'

export const load: PageLoad = async ({ fetch }) => {
	const response = await fetch('/api/data')

	if (!response.ok) {
		error(response.status, 'Failed to load data')
	}

	return await response.json()
}

#3: Returning Non-Serializable Data

// AVOID:  Functions can't travel from server to browser
export const load: PageLoad = async () => {
	return {
		formatDate: (d: Date) => d.toLocaleDateString()
	}
}

// PREFERRED: Return data, handle formatting in component
export const load: PageLoad = async () => {
	return {
		date: new Date().toISOString()
	}
}

Conclusion

The +page.js file embodies SvelteKit’s universal JavaScript philosophy—code that runs anywhere, providing consistent behavior whether executing during server-side rendering or client-side navigation. By running on both server and client, it enables optimal user experiences: fast initial loads with server rendering, instant subsequent navigations with client-side fetching, and shared data loading logic without duplication. This universality is SvelteKit’s secret weapon for building applications that feel fast everywhere while remaining fundamentally web-based.

Mastering +page.js means understanding the constraints of universal code (no window, no document, no private APIs), leveraging the enhanced fetch for automatic request deduplication and credentials, and using parent data access to build on layout-provided context. Combined with strategic preloading and proper dependency tracking with depends(), universal load functions create applications where data flows naturally from URLs to components, navigation feels instant, and the line between server and client becomes implementation detail rather than architectural burden.

Key Takeaways

  • +page.js runs universally - executes during SSR on the server and during client-side navigation in the browser, providing consistent data loading everywhere
  • Must use platform-agnostic code - no window, document, or browser-specific APIs since it runs on both server and client
  • Enhanced fetch provides superpowers - automatic cookie forwarding, request deduplication across load functions, and relative URL resolution during SSR
  • Access parent layout data via await parent() - build on authentication context, categories, or other layout-provided data without prop drilling
  • Preloading makes navigation instant - use data-sveltekit-preload-data on links to fetch page data on hover before navigation
  • Returned data must be serializable - only JSON-compatible data (no functions, classes, Dates, RegExp) to transfer from server to client
  • Use depends() for logical invalidation - create custom invalidation keys like depends('app:posts') to trigger reloads via invalidate()
  • Promise.all enables parallel fetching - combine multiple async operations to load related data simultaneously without sequential waterfalls

See Also