Universal vs server load: a hands-on guide for engineers
Every SvelteKit project faces the same question early on: where should data loading happen? Your routes need data, but the decision between +page.js (universal load) and +page.server.js (server load) affects security, performance, and architecture. We'll walk through when each belongs in your stack and show you the patterns that work in production.
Understanding the two load functions
SvelteKit gives us two ways to fetch data before rendering a page. A server load function runs only on the server (both during SSR and on navigation), while a universal load function runs on the server during SSR but then runs again in the browser on client-side navigation. This distinction shapes everything about what data you can access and how you protect it.
Server load functions live in +page.server.js or +layout.server.js. Universal load functions live in +page.js or +layout.js. When both exist, the server load runs first, and its result is passed to the universal load as a parameter.
Server load for databases and secrets
Anything sensitive stays on the server. Database connections, API keys, authentication tokens, and business logic that shouldn't be exposed to the client all belong in server load functions. Here's a concrete example from an e-commerce system where we fetch user orders:
// src/routes/orders/+page.server.js
import { error } from '@sveltejs/kit';
export async function load({ locals, params }) {
// locals.db is set by hooks.server.js, contains connection pool
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const orders = await locals.db.query(
'SELECT id, total, status FROM orders WHERE user_id = $1 ORDER BY created_at DESC',
[locals.user.id]
);
return {
orders,
userEmail: locals.user.email
};
}
This function runs on the server only. The database query never exposes connection strings or raw queries to the browser. The user authentication check happens server-side. When this data serializes to the client (more on that next), only the orders array and userEmail string reach the browser—never database credentials.
Server load also lets you read cookies directly, check authentication via session stores, and access environment variables. In a ride-hailing app, fetching the current driver's location or active ride from a private database is a perfect use case for server load.
Universal load for public APIs
When data is safe for the browser to request directly—think public REST endpoints, third-party APIs, or non-sensitive product catalogs—universal load functions make sense. They run on both server and client, so navigation between pages can fetch fresh data without hitting your server again.
Here's a travel booking site fetching public destination data:
// src/routes/destinations/[slug]/+page.js
export async function load({ params, fetch }) {
const res = await fetch(`/api/destinations/${params.slug}`);
if (!res.ok) {
throw error(404, 'Destination not found');
}
const destination = await res.json();
return {
destination
};
}
During SSR, this fetches from your API. On the client, clicking between destination pages triggers the same function again—but now in the browser, using the fetch parameter SvelteKit provides. The advantage: you avoid extra round trips through your server for data the client can fetch itself.
Notice we use the fetch parameter, not the global fetch. SvelteKit's fetch has special powers: during SSR it bypasses the network and hits your handlers directly, and it carries cookies and credentials automatically.
Universal load also works well for aggregating data from multiple sources. A dashboard that combines user preferences (from server load) with real-time market data (from a universal load calling an external API) can split responsibilities cleanly.
Server data serializes to the client
Here's a detail that trips up many engineers: whatever your server load function returns must be serializable to JSON. If you return a database connection object or a Date instance without thinking, SvelteKit will error or serialize it incorrectly.
This works fine:
// +page.server.js
export async function load({ locals }) {
const user = await locals.db.query('SELECT * FROM users WHERE id = $1', [1]);
return {
user: {
id: user.id,
name: user.name,
joinedAt: user.created_at.toISOString() // Convert Date to string
}
};
}
This will cause headaches:
// +page.server.js (WRONG)
export async function load({ locals }) {
const user = await locals.db.query('SELECT * FROM users WHERE id = $1', [1]);
return {
user, // Raw Date objects, extra fields, connection metadata—all problems
dbConnection: locals.db // Can't serialize
};
}
In a CRM application, we learned this the hard way when returning Contact objects with helper methods. The browser received a mangled object with missing functions. Now we shape data explicitly: transform database results into plain objects with only the fields the client needs.
Universal load functions face the same constraint—they must return serializable data since SvelteKit passes the result to the client and re-runs the function in the browser.
Layouts can load shared data
Both server and universal load functions can live in layout files. A +layout.server.js in your root or a nested route lets you load data once and pass it to all child pages. This is where you fetch user profile, navigation menus, or feature flags that every page needs.
Example from a SaaS app:
// src/routes/dashboard/+layout.server.js
export async function load({ locals }) {
if (!locals.user) {
throw error(401, 'Must log in');
}
const [workspace, permissions] = await Promise.all([
locals.db.query('SELECT * FROM workspaces WHERE id = $1', [locals.user.workspace_id]),
locals.db.query('SELECT role FROM user_roles WHERE user_id = $1', [locals.user.id])
]);
return {
workspace,
userRole: permissions[0].role
};
}
Now every page under /dashboard/* automatically receives workspace and userRole in its load data without duplicating the queries. Pages can add their own load functions that receive the layout data as a parameter.
Nested layouts stack. A layout in src/routes/dashboard/settings/+layout.server.js can load settings-specific data and combine it with data from parent layouts. The data flows down the component tree as props, so your Svelte components receive everything they need to render.
Combining server and universal load
Real applications rarely use one or the other exclusively. A page often needs both: server load to fetch sensitive database data, and universal load to call a third-party API or refresh data on navigation.
From a trading platform:
// src/routes/positions/+page.server.js
export async function load({ locals }) {
const positions = await locals.db.query(
'SELECT * FROM positions WHERE trader_id = $1',
[locals.user.id]
);
return { positions };
}
// src/routes/positions/+page.js
export async function load({ data, fetch }) {
// data contains the result from +page.server.js: { positions }
const priceRes = await fetch('/api/market-prices');
const prices = await priceRes.json();
return {
...data, // positions from server load
prices // current market data from universal load
};
}
The browser renders both datasets together. When the user navigates away and returns, the universal load re-runs in the browser to fetch fresh prices, while positions stay cached until a full page reload.
Common mistakes
Fetching secrets in universal load: Never call a protected API endpoint from +page.js. If it requires authentication headers or API keys, fetch it in server load instead. The universal load might run in the browser where those credentials are exposed.
Returning unserializable objects: Date objects, functions, and class instances don't serialize. Convert them to primitives before returning from any load function. We've seen this cause silent failures in production where data reaches the client as undefined.
Inefficient layout data: Loading massive datasets in a top-level layout can slow every page under it, even pages that don't need that data. Load only what's truly shared, and push heavy queries down to page-level load functions.
Ignoring the fetch parameter: In universal load, use SvelteKit's fetch instead of the global one. The SvelteKit version has special SSR handling and credential management that the global fetch doesn't provide.
Key takeaways
- Server load runs only on the server and is the right place for database queries, API calls that require secrets, and authentication logic.
- Universal load runs on the server during SSR but re-runs in the browser on navigation, making it ideal for public data and external APIs the client can safely request.
- All data returned from load functions must be JSON-serializable; shape and transform database results explicitly.
- Layout load functions provide a clean way to load shared data once and pass it down to all child pages.
- Combine server and universal load in the same route when you need sensitive and public data together; the universal load receives the server load result via the
dataparameter. - Use SvelteKit's
fetchparameter in universal load functions, not the globalfetch, for proper SSR and credential handling.






Comments
Loading comments…