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 .serverIf you’re unsure whether to use
+page.jsor+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
cookiesandlocalsobjects
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 TipIf 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 server | Yes (initial) | Yes (always) |
| Runs in browser | Yes (navigation) | Never |
| Database access | No | Yes |
| Private environment vars | No | Yes |
| Filesystem access | No | Yes |
Access to cookies | No | Yes |
Access to locals | No | Yes |
| Client-side navigation | Instant (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 TipWhen setting cookies, use the
httpOnly,secure, andsameSiteoptions to enhance security. For example,httpOnlyprevents 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/Feature | Serializable? | Notes |
|---|---|---|
| Strings, numbers, booleans, null, undefined | Yes | Basic primitives |
| Arrays, plain objects (nested) | Yes | Supports deep nesting |
| Dates (Date objects) | Yes | Dates are preserved as Date objects |
| Maps, Sets | Yes | Supported |
| BigInt | Yes | Supported |
| Regular expressions | Yes | Supported |
| Repeated/cyclical references | Yes | Supported |
| Functions | No | Not serializable |
| Classes with methods | No | Only plain data survives |
| Symbols | No | Not 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 TipStreaming 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:
+page.server.jsruns first (on the server)- Its returned data is available to
+page.jsas thedataproperty +page.jscan 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 TipChanges to
pageare 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 TipUse 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 APIs | You need database access |
| No secrets are involved | You use private API keys |
| You want instant client-side navigation | Data must stay on the server |
| The data can be safely exposed | You have sensitive business logic |
| You need browser-only APIs | You 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.jsprovides 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 fromdataprops 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
transporthook 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:enhanceprovide progressive enhancement for mutations, working without JavaScript while feeling like SPA interactions $app/stateprovides data access anywhere - read page data from any component without prop drilling using thepagerune
See Also
- Official SvelteKit Documentation - +page.server.js
- Form Actions - Server-side form handling
cookies- Session and authentication- Load Function Context - Understanding
locals,params,platform - Streaming with Promises - Progressive rendering patterns
getRequestEvent()- Accessing request context