Two Files, Two Environments
The previous two articles used +page.ts and +layout.ts for all load functions, which is the right starting point for understanding how the system works. But SvelteKit actually provides two distinct kinds of load functions, and the file you put them in determines something fundamental about how and where they execute.
The first kind, the one you have seen so far, is the universal load function. It lives in +page.ts or +layout.ts. The second kind is the server load function. It lives in +page.server.ts or +layout.server.ts. The names hint at the difference, but the implications run deeper than just “one runs on the server.” Both types can run on the server. What separates them is what they have access to, what they can return, and where they run during client-side navigation after the initial page load.
Getting this distinction right is one of the most consequential architectural decisions in a SvelteKit application. Put private database logic in a universal load function and you may accidentally expose it to the browser bundle. Put a function that needs to return a Svelte component constructor in a server load function and you will find it cannot be serialised over the wire. Understanding why each type exists, not just which file to use, is what allows you to make the right choice confidently every time.
Why Two File Types Exist
To understand why two types exist, you first need to understand what SvelteKit is doing when a user visits a page.
When a user navigates directly to a URL (by typing it into the address bar or following a link from another site), SvelteKit renders the page on the server and sends fully-formed HTML to the browser. The load function runs on the server, the component renders to a string, and the browser receives a complete document. This is server-side rendering (SSR).
When the same user then clicks a link within the application (navigating from one page to another without a full page reload), SvelteKit handles that navigation entirely in the browser. It calls the load function again, but this time in the browser, fetches only the new data, and updates the page without a round trip to the server for HTML. This is client-side navigation.
A universal load function participates in both of those scenarios. It runs on the server for the first visit and in the browser for subsequent navigations. This dual-environment execution is what “universal” means; the same function runs in both contexts.
A server load function, by contrast, always runs on the server. It runs on the server for the first visit, and it also runs on the server for client-side navigation; SvelteKit makes a network request to a special internal endpoint to call it, serialises the result, and sends it over the wire to the browser. The browser receives the data but never executes the function itself.
This distinction has profound consequences. A universal load function’s code is bundled for the browser, which means it must never contain anything that cannot safely exist in the browser: no direct database connections, no secret environment variables, no Node.js APIs. A server load function’s code never reaches the browser, so it can safely do all of those things.
The Trust Boundary Model
Think of the two types of load functions as occupying different trust boundaries.
Universal Load Functions
Trust Boundary in a universal load function is in the public trust boundary. It is visible to anyone who can access the page’s JavaScript bundle, which is essentially anyone who can load the page. This means that any code in a universal load function must be safe to run in the browser and must not contain any secrets or privileged access. It is appropriate for logic that has no secrets, no privileged access, and no server-only dependencies, such as fetching from a public API, transforming already-public data,and reading environment variables that are explicitly marked as public.
A universal load function runs in both environments. During SSR, it runs on the server and its return value is inlined into the HTML sent to the browser. During client-side navigation, it runs in the browser and can fetch data directly from public APIs without involving the server. Because it runs in the browser, it can return non-serialisable values like Svelte component constructors or class instances, which the server load function cannot do because its return value must be serialised to be sent over the network.
Server Load Functions
Trust Boundary in a server load function is in the private trust boundary. It is never sent to the browser and can only be read by developers with access to the server code. This means that any code in a server load function can safely contain secrets and privileged access, such as database queries, private environment variables, and internal APIs that require authentication. It is appropriate for anything that must remain confidential or that has server-only requirements, such as querying a database, reading $env/static/private variables, inspecting HTTP cookies, or calling an internal service with a private API key.
It is appropriate for anything that must remain confidential or that has server-only requirements, such as querying a database, reading $env/static/private variables, inspecting HTTP cookies, or calling an internal service with a private API key.
The boundary is enforced structurally by SvelteKit’s module system. Any import inside +page.server.ts that comes from $lib/server/ or uses $env/static/private is automatically blocked if it ever tries to run in the browser context. You do not have to manually ensure this separation, because the file naming convention is the enforcement mechanism.
The Combined Pattern
There is a third scenario that the official documentation covers but is easy to overlook: you can use both files for the same route simultaneously. When +page.server.ts and +page.ts both exist, SvelteKit runs the server load first and passes its return value as the data property of the universal load’s event argument. The universal load can then combine that server data with additional client-side processing before passing the final result to the component. This is advanced territory covered later in this article, but it is worth knowing the capability exists.
Implementation
Step 1: The Universal Load Function
A universal load function in +page.ts runs in both environments. Its most appropriate use case is fetching from an external API where no credentials are needed and where the browser could theoretically make the same request itself.
// src/routes/products/[id]/+page.ts
import type { PageLoad } from './$types'
import { error } from '@sveltejs/kit'
export const load: PageLoad = async ({ params, fetch }) => {
// This is a public API; no secret credentials required.
// The browser could call this URL directly. We are just doing it
// earlier, in the load function, so the page renders with data ready.
const response = await fetch(`https://api.storefront.example.com/products/${params.id}`)
if (!response.ok) {
error(response.status, `Product not found: ${params.id}`)
}
const product = await response.json()
return { product }
} <!-- src/routes/products/[id]/+page.svelte -->
<script lang="ts">
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
<h1>{data.product.name}</h1>
<p class="price">${data.product.price}</p>
<p>{data.product.description}</p> There is nothing secret here. The API URL, the product ID, and the response would all be visible to anyone who opened the browser’s network panel. Using a universal load function for this case is correct and efficient: during SSR, SvelteKit calls the API on the server and inlines the response into the HTML; during client-side navigation, the browser calls the same URL and updates the page directly. No intermediate server hop is needed.
The universal load function also has access to capabilities that server load functions do not. Most notably, it can return values that are not serialisable, such as Svelte component constructors, class instances, or Maps and Sets that you want to use directly in the component. Because the universal load runs in the same JavaScript environment as the component, it can pass rich values without serialisation.
Step 2: The Server Load Function
A server load function in +page.server.ts always runs on the server. It is the right choice whenever the load function needs privileged access or must work with server-only resources.
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { METRICS_API_URL, METRICS_API_KEY } from '$env/static/private'
import { error, redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ locals, fetch }) => {
// locals.user is populated by the authentication hook in src/hooks.server.ts.
// Only server load functions can read locals — universal load functions cannot.
if (!locals.user) {
redirect(307, '/login')
}
// Calling a private internal service using a secret environment variable.
// METRICS_API_KEY never reaches the browser — it is server-only.
const metricsRes = await fetch(`${METRICS_API_URL}/users/${locals.user.id}`, {
headers: { Authorization: `Bearer ${METRICS_API_KEY}` }
})
if (!metricsRes.ok) {
error(502, 'Metrics service unavailable')
}
return {
user: {
id: locals.user.id,
name: locals.user.name,
email: locals.user.email,
plan: locals.user.plan
},
metrics: await metricsRes.json()
}
} Several capabilities used here are exclusive to server load functions. locals is available only in +page.server.ts and +layout.server.ts; universal load functions cannot read it. $env/static/private can only be imported in server-only contexts — the bundler enforces this at build time. None of this code touches the browser.
locals is a typed object populated by SvelteKit server hooks in src/hooks.server.ts. The hook runs on every request before any load function, authenticates the session, and attaches the resolved user to locals. Server load functions then read locals.user directly — no secondary request needed. This is the standard SvelteKit pattern for authentication: hooks handle identity resolution, server load functions consume the result.
The return value of a server load function must be serialisable using the devalue library, which is a superset of JSON that also handles Date, Map, Set, BigInt, RegExp, and circular references. Class instances with methods, Svelte component constructors, and arbitrary functions cannot be serialised and therefore cannot be returned from a server load function. If you need to return something non-serialisable, that is a signal that a universal load function, possibly combined with a server load function, is the right architecture.
Step 3: Recognising What You Cannot Do in Each Context
The practical way to internalise the distinction is to understand what will break in each context. Universal load functions will fail if they try to import server-only modules. Server load functions will fail if they try to return non-serialisable values. These failures manifest as TypeScript errors or runtime errors that SvelteKit makes explicit, which makes them easier to catch than silent logical errors.
Here is a table of the key capabilities and which load type supports them:
| Capability | Universal (+page.ts) | Server (+page.server.ts) |
|---|---|---|
| Runs on server (SSR) | Yes | Yes |
| Runs in browser (navigation) | Yes | No (data fetched via internal API) |
Access to cookies | No | Yes |
Access to locals | No | Yes |
Access to $env/static/private | No | Yes |
$lib/server/ modules | No | Yes |
| Return non-serialisable values | Yes | No |
| Code appears in browser bundle | Yes | No |
If a load function does not need anything in the right column, a universal load in +page.ts is fine. If it needs anything in the right column, it must go in +page.server.ts.
Step 4: Combining Both in the Same Route
There are situations where neither type alone is sufficient. The most common case is a route that needs server-only data (for example, a user object from the database) and also needs to return something non-serialisable (for example, a dynamically selected Svelte component based on the user’s role).
SvelteKit supports this by allowing both +page.server.ts and +page.ts to coexist for the same route. When both exist, SvelteKit runs the server load first, then passes its return value as data in the universal load’s event argument. The universal load combines the server data with whatever client-side processing it needs and returns the final result to the component.
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ locals }) => {
// locals.user is set by the auth hook — only readable in server load functions.
if (!locals.user) redirect(307, '/login')
return {
userId: locals.user.id,
role: locals.user.role as 'admin' | 'editor' | 'viewer',
displayName: locals.user.displayName
}
} // src/routes/dashboard/+page.ts
import type { PageLoad } from './$types'
// These are Svelte component constructors; non-serialisable.
// They can only be passed through a universal load function.
import AdminDashboard from '$lib/components/AdminDashboard.svelte'
import EditorDashboard from '$lib/components/EditorDashboard.svelte'
import ViewerDashboard from '$lib/components/ViewerDashboard.svelte'
const DASHBOARD_COMPONENTS = {
admin: AdminDashboard,
editor: EditorDashboard,
viewer: ViewerDashboard
}
export const load: PageLoad = async ({ data }) => {
// `data` here is the return value of +page.server.ts.
// The universal load receives it and can extend or transform it.
const DashboardComponent = DASHBOARD_COMPONENTS[data.role]
return {
// Spread the server data through so the component receives it all.
...data,
// Add the component constructor (something the server load cannot return).
DashboardComponent
}
} <!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
<h1>Welcome, {data.displayName}</h1>
<!-- Dynamically render whichever dashboard component the user's role requires. -->
{@const DashboardComponent = data.DashboardComponent}
<DashboardComponent userId={data.userId} /> This pattern keeps the privilege boundary clean: the server load handles everything that needs server-only access, the universal load handles everything that needs non-serialisable values, and the component receives a clean merged result from both. The server load’s return value is the data property of the universal load event; it flows through the universal load but is not automatically passed to the component. The universal load is responsible for including whatever server data the component needs in its own return value, typically by spreading ...data.
Common Mistakes and Anti-Patterns
Using Server-Only Imports in a Universal Load Function
This is the most dangerous mistake in this area. It does not always fail immediately, it may work in development, then expose private credentials in the browser bundle in production, or crash when Node.js-only code runs in a browser context.
// src/routes/profile/+page.ts
// AVOID: Importing a private environment variable in a universal load function.
// This file is bundled for the browser. PRIVATE_API_KEY would be visible
// to anyone who inspects the JavaScript bundle.
import { PRIVATE_API_KEY } from '$env/static/private'
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params, fetch }) => {
const res = await fetch(`/api/users/${params.id}`, {
headers: { Authorization: `Bearer ${PRIVATE_API_KEY}` } // key exposed!
})
return { user: await res.json() }
} // src/routes/profile/+page.server.ts
// PREFERRED: Private credentials belong in a server load function.
// This file never reaches the browser bundle.
import type { PageServerLoad } from './$types'
import { PRIVATE_API_KEY } from '$env/static/private'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, fetch }) => {
const res = await fetch(`/api/users/${params.id}`, {
headers: { Authorization: `Bearer ${PRIVATE_API_KEY}` } // safe, server-only
})
if (!res.ok) error(404, 'User not found')
return { user: await res.json() }
} SvelteKit throws a build-time error if you import $env/static/private or anything from $lib/server/ in a universal load function — the bundler recognises those as server-only modules and refuses to include them in the client bundle. The protection relies on the path convention: any file under $lib/server/ is treated as server-only automatically. Keep all privileged logic there so the tooling can enforce the boundary on your behalf.
Trying to Return a Class Instance From a Server Load Function
When a server load function returns data, SvelteKit serialises it using devalue before sending it across the network to the browser. Class instances with methods, prototype chains, and non-enumerable properties do not survive serialisation. What arrives in the browser is a plain object with the same enumerable keys; the methods and prototype are gone.
// src/routes/cart/+page.server.ts
class ShoppingCart {
items: CartItem[]
constructor(items: CartItem[]) {
this.items = items
}
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0)
}
addItem(item: CartItem) {
/* ... */
}
}
// AVOID: The ShoppingCart class instance cannot be serialised.
// The browser will receive { items: [...] } with no methods.
export const load: PageServerLoad = async ({ locals, fetch }) => {
if (!locals.user) redirect(307, '/login')
const res = await fetch(`/api/cart/${locals.user.id}`)
const items: CartItem[] = await res.json()
return { cart: new ShoppingCart(items) }
} // PREFERRED option A: Return plain data from the server load.
// Reconstruct the class in the universal load if needed.
// +page.server.ts
export const load: PageServerLoad = async ({ locals, fetch }) => {
if (!locals.user) redirect(307, '/login')
const res = await fetch(`/api/cart/${locals.user.id}`)
const cartItems: CartItem[] = await res.json()
return { cartItems } // plain serialisable data
}
// +page.ts
export const load: PageLoad = async ({ data }) => {
// Reconstruct the class in the universal load where it can survive.
const cart = new ShoppingCart(data.cartItems)
return { cart }
} The split-file pattern exists precisely for this scenario. The server load handles privileged data retrieval. The universal load reconstructs rich objects that benefit from class behaviour. Each does what it is suited for.
Confusing Which data Is Which When Using Both Files
When both +page.server.ts and +page.ts exist for the same route, there are two different things called data in close proximity: the data property in the universal load’s event argument (which contains the server load’s return value), and the data prop in the component (which contains the universal load’s return value). These are distinct and can differ if the universal load does not spread the server data through.
// +page.server.ts
export const load: PageServerLoad = async () => {
return { serverMessage: 'hello from server' }
}
// +page.ts (AVOID): Forgetting to include server data in the return value
export const load: PageLoad = async ({ data }) => {
// data.serverMessage is available here, but we are not returning it.
return { universalMessage: 'hello from universal' }
// The component will only see universalMessage.
// serverMessage is lost because the universal load did not pass it through.
} // +page.ts (PREFERRED): Spread server data into the universal return value
export const load: PageLoad = async ({ data }) => {
return {
...data, // includes serverMessage
universalMessage: 'hello from universal'
}
// The component now sees both serverMessage and universalMessage.
} This is a purely mechanical mistake but it produces confusing symptoms; the TypeScript types for data in the component include serverMessage (because SvelteKit can see both return types), but the value is undefined at runtime because the universal load never passed it through. Always spread ...data when you want server load data to reach the component.
Performance and Scaling Considerations
The choice between universal and server load functions has meaningful performance implications that go beyond correctness.
Universal load functions allow SvelteKit to potentially skip the server entirely during client-side navigation. If your universal load fetches from a public CDN-cached API, the browser can hit that cache directly on every navigation without touching your server. Server load functions, by contrast, always involve a round trip to your server during client-side navigation; SvelteKit makes a fetch to an internal endpoint, your server runs the load function, and the result travels back over the wire. For high-traffic applications, universal loads that use well-cached public APIs can significantly reduce server load.
Server load functions, however, have their own performance advantage: they run closer to your data. A server load function that queries a local database has network latency measured in milliseconds. A universal load function calling an external API from the browser may have latency measured in hundreds of milliseconds, plus the overhead of CORS and authentication token transmission. For data that genuinely requires server-side access, the server load’s proximity to the data source is a feature, not just a constraint.
The combined pattern (server load plus universal load) has the overhead of both: the server load runs on the server, and then the universal load runs in the appropriate environment, using the server load’s result. Use the combined pattern only when you actually need both capabilities; do not default to it out of habit.
When NOT to Use This Pattern
The combined +page.server.ts plus +page.ts pattern is powerful but represents additional complexity. Most routes need only one or the other. If you find yourself reaching for the combined pattern frequently, it may be a sign that your route is doing too much; fetching privileged server data and constructing rich client-side objects that would be better separated into distinct routes or components.
The universal load function is not appropriate as a workaround for making server data “more convenient.” If data requires server-only access, it belongs in a server load function, full stop. Using a universal load function and then manually guarding against browser execution defeats the structural safety that the file naming convention provides.
Conclusion
Universal and server load functions occupy different environments and carry different capabilities. Universal load functions run in both server and browser, belong in +page.ts, and are appropriate for public data and non-serialisable return values. Server load functions always run on the server, belong in +page.server.ts, and are the correct home for anything that requires privileged access, such as database queries, private environment variables, and session cookies. The file convention is not just organisational; it is the enforcement mechanism for a meaningful security boundary.
When you understand not just which file to use but why the distinction exists, the choice becomes straightforward: ask what the load function needs access to, and let the answer tell you which file it belongs in.
A Note on Where the Ecosystem Is Heading
SvelteKit is developing an experimental feature called Remote Functions that approaches the client-server boundary from a different angle. Rather than structuring server logic around route files, Remote Functions let you define server-side functions in .remote.ts files and call them from anywhere in your component tree, including directly from component markup using the async Svelte compiler option.
Remote Functions are still under the experimental flag as of this writing and the API has been changing meaningfully with each release. They are not covered in this series because teaching an API that may break between minor versions of SvelteKit would do readers a disservice. When they stabilise, they will deserve their own dedicated series. In the meantime, the load function patterns covered here are the production-ready foundation that Remote Functions themselves build upon.
Key Takeaways
Universal load functions (+page.ts) run on the server during SSR and in the browser during client-side navigation. They can return non-serialisable values but must never contain server-only imports or private credentials, because their code is bundled for the browser.
Server load functions (+page.server.ts) always run on the server. They have exclusive access to cookies, locals, private environment variables, and server-only modules. Their return values must be serialisable because they travel over the network to the browser.
When a route needs both privileged data access and non-serialisable return values, both files can coexist. The server load runs first and its return value arrives as data in the universal load’s event argument. The universal load is responsible for including whatever server data the component needs in its own return value.
The file naming convention is the security boundary. Importing $lib/server/ modules in a universal load function is a build-time error. Returning non-serialisable values from a server load function is a runtime error. Both failures are explicit by design.
What’s Next
With a solid understanding of the two load function types, it is time to look at what information a load function can draw on beyond just knowing which route is active. The URL itself contains a wealth of data (path parameters, query strings, and the full pathname), and SvelteKit gives load functions structured, typed access to all of it. The next article, Using URL data in Load Functions, covers how to use that URL data effectively in both universal and server load functions, and how to keep your URL structure clean and intuitive as your application grows.
Further Reading
- SvelteKit Server-Only Modules (how the
$lib/server/boundary is enforced) - SvelteKit Page Options (controlling SSR and prerendering, which affects when load functions run)
- Official SvelteKit Load Documentation (the authoritative reference for all load function options and types)