Server-Only Data Loading

Sometimes you need capabilities that only exist on the server: database queries, private API keys, filesystem access, or sensitive business logic. The +page.server.js file (or +page.server.ts) provides server-only data loading that never runs in the browser.

The distinction from +page.js is absolute, not a convention. Files ending in .server.js are excluded from the client bundle at build time. Vite physically removes them before shipping JavaScript to the browser. This means your database credentials, private API keys, and internal business logic cannot leak to the client no matter what — they are not in the bundle at all.

The practical consequence: in +page.server.js, you can freely import from $lib/server/, use $env/static/private, call cookies.get(), and query your database directly. None of it reaches the client. The data you return does cross the network (serialized as JSON), but the code that produces it stays on the server.

When in doubt, use .server

If you’re unsure whether to use +page.js or +page.server.js, choose .server. You can always switch to universal loading later if you discover you need instant client-side navigation for that route. Defaulting to server-only loading is the safer choice.


When to Use Server-Only Loading

Use +page.server.js when you need:

  • Database access - Direct queries without exposing connection strings
  • Private API keys - Third-party services that require authentication
  • Filesystem operations - Reading files from disk
  • Sensitive business logic - Calculations or validations you don’t want exposed
  • Access to cookies or session data - Via the cookies and locals objects

Your First Server Load Function

// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { db } from '$lib/server/database'
import { PRIVATE_API_KEY } from '$env/static/private'

export const load: PageServerLoad = async ({ locals }) => {
	// This code NEVER runs in the browser
	// It's completely safe to use secrets here

	// Direct database access
	const user = await db.users.findUnique({
		where: { id: locals.userId }
	})

	// Private API calls
	const analyticsResponse = await fetch('https://api.analytics.com/data', {
		headers: {
			Authorization: `Bearer ${PRIVATE_API_KEY}`
		}
	})
	const analytics = await analyticsResponse.json()

	return {
		user: {
			name: user.name,
			email: user.email
			// Don't expose sensitive fields like passwordHash!
		},
		analytics
	}
}

Consuming Data in Components with Svelte 5 Runes

When your +page.server.ts returns data, you access it in your +page.svelte component using the $props() rune. Understanding how to properly handle this data with Svelte 5’s reactivity system is crucial.

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

	// PageProps automatically types both 'data' and 'form'
	let { data }: PageProps = $props()
</script>

<h1>Welcome, {data.user.name}</h1>

Why Reactivity Matters

The Component Reuse Pattern

SvelteKit reuses page components during client-side navigation rather than destroying and recreating them. When you navigate from /dashboard to /dashboard?filter=active, or from /blog/post-a to /blog/post-b if they share the same layout, the component stays mounted and only the data prop updates with fresh values.

This is intentional and beneficial — it avoids layout thrash, keeps animations smooth, and preserves local state like scroll position and open dropdowns. But it has an important consequence: any computed values derived from data must be reactive, or they’ll show stale data after navigation.

In Svelte 5, computed values from props that need to stay current belong in $derived(). Plain variable assignments run once at component creation and don’t update when props change:

<!-- AVOID: Plain assignment runs once on component creation.
     After client-side navigation updates data, these values stay stale. -->
<script lang="ts">
	import type { PageProps } from './$types'

	let { data }: PageProps = $props()

	const postCount = data.posts.length // frozen at mount
	const featuredPost = data.posts.find((p) => p.featured) // frozen at mount
</script>
<!-- PREFERRED: $derived recomputes whenever data changes, including during navigation. -->
<script lang="ts">
	import type { PageProps } from './$types'

	let { data }: PageProps = $props()

	let postCount = $derived(data.posts.length)
	let featuredPost = $derived(data.posts.find((p) => p.featured))
	let hasMultiplePosts = $derived(postCount > 1)
	let featuredTitle = $derived(featuredPost?.title ?? 'No featured post')
</script>

<h1>Blog ({postCount} posts)</h1>

{#if featuredPost}
	<article class="featured">
		<h2>{featuredTitle}</h2>
	</article>
{/if}

{#if hasMultiplePosts}
	<p>Browse our collection of articles</p>
{/if}
Reactivity Tip

If a value is calculated from data (or any prop), wrap it in $derived(). This ensures your UI stays in sync during navigation.


Key Differences from +page.js

Feature+page.js+page.server.js
Runs on serverYes (initial)Yes (always)
Runs in browserYes (navigation)Never
Database accessNoYes
Private environment varsNoYes
Filesystem accessNoYes
Access to cookiesNoYes
Access to localsNoYes
Client-side navigationInstant (cached)Requires server

Server-Only Parameters

The load function in +page.server.js has access to additional server-only parameters:

export const load: PageServerLoad = async ({
	params, // Route parameters
	url, // URL object
	fetch, // Server-side fetch with special powers
	cookies, // Read and write cookies
	locals, // Data from hooks.server.ts
	request, // The original Request object
	platform, // Platform-specific context (Vercel, Cloudflare, etc.)
	setHeaders // Set response headers
}) => {
	// Your server-only logic
}

Working with Cookies

Cookies are small pieces of data stored by the browser and sent with every request to your server. They are commonly used for authentication (sessions), user preferences, and tracking information that needs to persist between requests or across browser sessions.

In SvelteKit, you can only read and write cookies in server-side code—such as +page.server.ts, +layout.server.ts, or endpoint files—because cookies are part of the HTTP request/response cycle.

// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ cookies }) => {
	// Read a cookie
	const sessionId = cookies.get('session')

	// Validate session and get user
	const user = await validateSession(sessionId)

	return { user }
}
Cookie Security Tip

When setting cookies, use the httpOnly, secure, and sameSite options to enhance security. For example, httpOnly prevents JavaScript from accessing the cookie, reducing the risk of XSS attacks.

Using locals from Hooks

The locals object is populated in your hooks.server.ts file and is available in all server load functions. This is the recommended pattern for sharing data like authenticated user information across your application.

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'
import { getUserFromSession } from '$lib/server/auth'

export const handle: Handle = async ({ event, resolve }) => {
	// Populate locals with user data - runs on every request
	const session = event.cookies.get('session')
	event.locals.user = await getUserFromSession(session)

	return resolve(event)
}
// src/routes/profile/+page.server.ts
import type { PageServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ locals }) => {
	// Access the user set in hooks
	if (!locals.user) {
		redirect(303, '/login')
	}

	return {
		user: locals.user
	}
}

Reusable Auth Guards with getRequestEvent

For larger applications, you’ll want reusable auth logic. The getRequestEvent function (from $app/server) lets you access the current request from anywhere in your server code:

// src/lib/server/auth.ts
import { redirect } from '@sveltejs/kit'
import { getRequestEvent } from '$app/server'

/**
 * Reusable auth guard - call from any server load function or form action.
 * Redirects to login if user is not authenticated.
 * @returns The authenticated user (guaranteed non-null)
 */
export function requireAuth() {
	const { locals, url } = getRequestEvent()

	if (!locals.user) {
		// Preserve intended destination for post-login redirect
		const redirectTo = url.pathname + url.search
		const params = new URLSearchParams({ redirectTo })
		redirect(303, `/login?${params}`)
	}

	return locals.user
}

/**
 * Check if user has a specific role
 */
export function requireRole(role: 'admin' | 'editor' | 'user') {
	const user = requireAuth() // First ensure they're logged in

	if (user.role !== role && user.role !== 'admin') {
		redirect(303, '/unauthorized')
	}

	return user
}

Now your load functions become clean and declarative:

// src/routes/admin/+page.server.ts
import type { PageServerLoad } from './$types'
import { requireRole } from '$lib/server/auth'
import { db } from '$lib/server/database'

export const load: PageServerLoad = async () => {
	// Throws redirect if not admin - no need to check return value
	const admin = requireRole('admin')

	// If we reach here, user is guaranteed to be an admin
	const stats = await db.getAdminStats()

	return {
		admin: { name: admin.name, email: admin.email },
		stats
	}
}

The Serialization Requirement

Data returned from +page.server.js must travel from server to client over the network. The code stays on the server, but the data must be serializable — convertible to a format that can be embedded in HTML or sent as JSON and then reconstructed in the browser.

This is why you can’t return functions from a server load function. A function is executable code; it can’t be serialized into bytes that travel over HTTP and then reconstructed on the other end. Return the result of calling functions, not the functions themselves.

// AVOID Functions can't be serialized
export const load: PageServerLoad = async () => {
	return {
		calculateTotal: (items) => items.reduce((a, b) => a + b, 0)
	}
}

// PREFERRED: Return data, not functions
export const load: PageServerLoad = async () => {
	const items = await getItems()
	return {
		items,
		total: items.reduce((a, b) => a + b.price, 0)
	}
}

What Can Be Serialized?

SvelteKit uses devalue for serialization, which supports more than plain JSON:

Type/FeatureSerializable?Notes
Strings, numbers, booleans, null, undefinedYesBasic primitives
Arrays, plain objects (nested)YesSupports deep nesting
Dates (Date objects)YesDates are preserved as Date objects
Maps, SetsYesSupported
BigIntYesSupported
Regular expressionsYesSupported
Repeated/cyclical referencesYesSupported
FunctionsNoNot serializable
Classes with methodsNoOnly plain data survives
SymbolsNoNot serializable

Custom Type Serialization with transport

For custom classes (like Decimal for financial calculations), use the transport hook:

// src/hooks.ts
import type { Transport } from '@sveltejs/kit'
import { Decimal } from 'decimal.js'

export const transport: Transport = {
	Decimal: {
		// encode runs on the server - return false/null/undefined to skip
		encode: (value) => value instanceof Decimal && value.toString(),
		// decode runs on the client - reconstruct the instance
		decode: (str) => new Decimal(str)
	}
}

Now you can return Decimal instances from your load functions:

// src/routes/invoice/+page.server.ts
export const load: PageServerLoad = async ({ params }) => {
	const invoice = await db.invoices.findUnique({ where: { id: params.id } })

	return {
		invoice: {
			...invoice,
			// This Decimal instance will survive the server→client journey
			total: new Decimal(invoice.total)
		}
	}
}

Streaming with Promises

If you have data that is slow to load and not essential for the initial page render, you can stream it to the browser. By returning the promise without awaiting it, SvelteKit will render the main content immediately and load the slow data in the background.

// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ locals }) => {
	// Fast, critical data - AWAIT this
	const user = await db.users.findUnique({
		where: { id: locals.userId }
	})

	// Slow, non-critical data - DON'T await, let it stream
	const analyticsPromise = fetchSlowAnalytics(locals.userId)
	const recommendationsPromise = generateRecommendations(locals.userId)

	return {
		user, // Available immediately
		analytics: analyticsPromise, // Streams in later
		recommendations: recommendationsPromise // Streams in later
	}
}

Handle streamed data in your component with {#await}:

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

	let { data }: PageProps = $props()
</script>

<!-- This renders immediately -->
<h1>Welcome back, {data.user.name}</h1>

<!-- Analytics streams in with loading state -->
<section class="analytics">
	{#await data.analytics}
		<div class="skeleton">
			<p>Loading analytics...</p>
		</div>
	{:then analytics}
		<h2>Your Stats</h2>
		<p>Page views: {analytics.pageViews}</p>
		<p>Conversions: {analytics.conversions}</p>
	{:catch error}
		<div class="error">
			<p>Failed to load analytics: {error.message}</p>
		</div>
	{/await}
</section>
Performance Tip

Streaming is excellent for dashboards, analytics, and secondary content. The user sees the page layout immediately while slower data fills in progressively.


Combining with +page.js

You can have both +page.js and +page.server.js for the same route. When you do:

  1. +page.server.js runs first (on the server)
  2. Its returned data is available to +page.js as the data property
  3. +page.js can transform or add to this data
// src/routes/blog/+page.server.ts
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async () => {
	const posts = await db.posts.findMany()
	return { posts }
}
// src/routes/blog/+page.ts
import type { PageLoad } from './$types'

export const load: PageLoad = async ({ data }) => {
	// `data` contains { posts } from +page.server.ts

	return {
		posts: data.posts,
		// Add client-side only data
		viewedAt: new Date().toISOString(),
		// Or transform the data
		postCount: data.posts.length
	}
}

Accessing Page Data from Anywhere with $app/state

The page object from $app/state provides a global, reactive store containing all data returned from your current page and layouts, as well as the current URL and navigation state. This means any component can access up-to-date page data without prop drilling.

<!-- src/lib/components/UserBadge.svelte -->
<script lang="ts">
	import { page } from '$app/state'

	// Access combined data from all layouts and current page
	// IMPORTANT: Use $derived since page is reactive
	let userName = $derived(page.data.user?.name ?? 'Guest')
	let isAdmin = $derived(page.data.user?.role === 'admin')
</script>

<div class="badge">
	<span>{userName}</span>
	{#if isAdmin}
		<span class="admin-badge">Admin</span>
	{/if}
</div>
Reactivity Tip

Changes to page are only reactive when accessed through $derived(). Always use runes for reactivity with the page object.


Form Actions: Handling POST Requests

+page.server.js can also export actions to handle form submissions. This is SvelteKit’s recommended approach for mutations (creating, updating, deleting data).

// src/routes/contact/+page.server.ts
import type { Actions, PageServerLoad } from './$types'
import { fail } from '@sveltejs/kit'
import { db } from '$lib/server/database'

export const actions: Actions = {
	default: async ({ request }) => {
		const formData = await request.formData()
		const email = formData.get('email')
		const message = formData.get('message')

		// Validate
		if (!email) {
			return fail(400, {
				error: 'Email is required',
				email: '',
				message: message?.toString() ?? ''
			})
		}

		if (!message) {
			return fail(400, {
				error: 'Message is required',
				email: email.toString(),
				message: ''
			})
		}

		// Save to database
		await db.messages.create({
			data: {
				email: email.toString(),
				message: message.toString()
			}
		})

		return { success: true }
	}
}

Progressive Enhancement with use:enhance

SvelteKit forms work without JavaScript, but you can progressively enhance them for a better user experience. The use:enhance action prevents full page reloads while maintaining all the benefits of server-side validation.

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

	let { form }: PageProps = $props()

	// Local UI state for submission feedback
	let isSubmitting = $state(false)

	// Derived values for cleaner template logic
	let hasError = $derived(!!form?.error)
	let wasSuccessful = $derived(!!form?.success)
</script>

{#if wasSuccessful}
	<div class="success" role="alert">
		<p>✓ Message sent successfully!</p>
	</div>
{/if}

{#if hasError}
	<div class="error" role="alert">
		<p>{form.error}</p>
	</div>
{/if}

<form
	method="POST"
	use:enhance={() => {
		isSubmitting = true

		return async ({ update }) => {
			await update()
			isSubmitting = false
		}
	}}
>
	<div class="field">
		<label for="email">Email</label>
		<input
			id="email"
			name="email"
			type="email"
			value={form?.email ?? ''}
			disabled={isSubmitting}
			required
		/>
	</div>

	<div class="field">
		<label for="message">Message</label>
		<textarea id="message" name="message" disabled={isSubmitting} required
			>{form?.message ?? ''}</textarea
		>
	</div>

	<button type="submit" disabled={isSubmitting}>
		{#if isSubmitting}
			Sending...
		{:else}
			Send Message
		{/if}
	</button>
</form>

<style>
	.success {
		padding: 1rem;
		background: #d4edda;
		border: 1px solid #c3e6cb;
		border-radius: 4px;
		color: #155724;
	}

	.error {
		padding: 1rem;
		background: #f8d7da;
		border: 1px solid #f5c6cb;
		border-radius: 4px;
		color: #721c24;
	}

	button:disabled {
		opacity: 0.6;
		cursor: not-allowed;
	}
</style>

Named Actions

When your page needs to support multiple types of form submissions, you can define named actions. Each named action is a function keyed by its name, and you target them from your form using the action attribute.

// src/routes/posts/[id]/+page.server.ts
import type { Actions } from './$types'
import { db } from '$lib/server/database'
import { redirect } from '@sveltejs/kit'

export const actions: Actions = {
	update: async ({ params, request }) => {
		const formData = await request.formData()
		await db.posts.update({
			where: { id: params.id },
			data: { title: formData.get('title')?.toString() }
		})
		return { success: true }
	},

	delete: async ({ params }) => {
		await db.posts.delete({ where: { id: params.id } })
		redirect(303, '/posts')
	}
}
<!-- src/routes/posts/[id]/+page.svelte -->
<script lang="ts">
	import type { PageProps } from './$types'
	import { enhance } from '$app/forms'

	let { data }: PageProps = $props()
</script>

<!-- Edit form targets the 'update' action -->
<form method="POST" action="?/update" use:enhance>
	<input name="title" value={data.post.title} />
	<button type="submit">Save</button>
</form>

<!-- Delete form targets the 'delete' action -->
<form method="POST" action="?/delete" use:enhance>
	<button type="submit">Delete Post</button>
</form>
Organization Tip

Use named actions for any page with more than one form or mutation type. This keeps your code organized and explicit.

Security Best Practices

Don’t Expose Sensitive Data

Always be intentional about what you send to the client. Accidentally exposing sensitive fields is a common and serious security risk.

// AVOID: Exposing the entire user object
export const load: PageServerLoad = async ({ locals }) => {
	const user = await db.users.findUnique({
		where: { id: locals.userId }
	})
	return { user } // Includes passwordHash, internalNotes, etc!
}

// PREFERRED: Explicitly select only what the client needs
export const load: PageServerLoad = async ({ locals }) => {
	const user = await db.users.findUnique({
		where: { id: locals.userId },
		select: {
			id: true,
			name: true,
			email: true,
			avatar: true
		}
	})
	return { user }
}

Validate User Permissions

Always enforce authentication and authorization checks on the server. Client-side checks can be bypassed.

import { error, redirect } from '@sveltejs/kit'

export const load: PageServerLoad = async ({ locals, params }) => {
	// Check authentication
	if (!locals.user) {
		redirect(303, '/login')
	}

	// Fetch the resource
	const post = await db.posts.findUnique({
		where: { id: params.id }
	})

	// Check it exists
	if (!post) {
		error(404, 'Post not found')
	}

	// Check authorization
	if (post.authorId !== locals.user.id && locals.user.role !== 'admin') {
		error(403, 'You do not have permission to view this post')
	}

	return { post }
}

Type-Safe Error Handling

Define your error shapes in src/app.d.ts for type safety and consistency:

// src/app.d.ts
declare global {
	namespace App {
		interface Error {
			message: string
			code?: string
			details?: Record<string, string>
		}
		interface Locals {
			user?: {
				id: string
				name: string
				email: string
				role: 'admin' | 'editor' | 'user'
			}
		}
	}
}

export {}

When to Choose Which

Use +page.js when…Use +page.server.js when…
Data comes from public APIsYou need database access
No secrets are involvedYou use private API keys
You want instant client-side navigationData must stay on the server
The data can be safely exposedYou have sensitive business logic
You need browser-only APIsYou need cookies or session data

Conclusion

The +page.server.js file is SvelteKit’s security boundary—where sensitive operations stay safely on the server, never exposed to the browser. By running load functions exclusively on the server during both SSR and client-side navigation, it enables direct database access, private API keys, and business logic that must remain confidential.

This server-only execution model transforms data loading from a security concern into a development convenience, letting you write straightforward database queries and API calls without worrying about credential exposure.

Mastering +page.server.js means understanding the full server-side toolkit: accessing cookies for authentication, leveraging locals for request-scoped data, streaming promises for progressive rendering, and combining load functions with form actions for complete CRUD operations. When combined with proper reactivity using $derived() for computed values and $state() for component state, server-loaded data flows naturally through navigation while maintaining the security guarantees that keep your application safe.

The key is knowing when to use server-only loading versus universal loading—use +page.server.js when security or server-specific APIs are involved, +page.js when you want instant client-side navigation with public data.

Key Takeaways

  • +page.server.js provides server-only data loading that never runs in the browser, perfect for database access, private API keys, and sensitive business logic
  • Use $derived() for computed values from data props to maintain reactivity during client-side navigation in Svelte 5
  • Use $state() for local component state like loading indicators, form values, or UI toggles that don’t come from the server
  • getRequestEvent() enables reusable auth guards and shared server logic by providing access to cookies, locals, and request context from anywhere
  • Data must be serializable - only JSON-compatible types, use the transport hook to customize serialization for Dates, Maps, or custom classes
  • Promise streaming allows progressive rendering - return promises directly from load to render page shells immediately while slow data loads in the background
  • Form actions with use:enhance provide progressive enhancement for mutations, working without JavaScript while feeling like SPA interactions
  • $app/state provides data access anywhere - read page data from any component without prop drilling using the page rune

See Also