The Opportunity Before the Page Renders
A user submits a login form. The server creates a session, issues a cookie, and redirects them to their dashboard. On that very next request, before a single line of the dashboard component runs, your application has one opportunity: read the cookie, validate the session, resolve the user’s identity. The entire page depends on getting that right in the fraction of a second between the request arriving and the response leaving.
That opportunity lives inside a server load function. Using it correctly means understanding three things that sit close together but behave differently: reading cookies with the cookies object, setting cookies with cookies.set() rather than through a response header, and setting other response headers with setHeaders in a way that accounts for how SSR and client navigation interact differently.
Most tutorials on this subject reduce cookies to a single line: cookies.get('session'). That gets you started, but it leaves out the reasoning that determines when reading a cookie is the right approach, why setting a cookie requires a different API entirely, how cookie forwarding rules interact with multi-domain deployments, and what setHeaders actually does during a server-rendered request versus a client navigation.
This article works through all of it. By the end you will have a precise mental model of how the cookies object and setHeaders function behave in server load functions, why they work the way they do, and how to apply them to a practical session loading pattern that serves as the foundation for authentication in most SvelteKit applications.
One convention continues from the rest of this series: no ORMs, no database client abstractions. Every data access in the examples uses plain fetch calls to API endpoints. This keeps the examples focused on the SvelteKit behavior being explained rather than on any particular data layer.
Four Concerns, One Request
Consider a typical authenticated SvelteKit application. When a user logs in, the server creates a session record and sends the session identifier back to the browser as an HTTP cookie. Every subsequent request from that browser carries the cookie. The server reads it, looks up the session, and knows who the user is.
In SvelteKit, server load functions are where this lookup happens. The +layout.server.ts at the root of the application reads the session cookie, resolves it to a user identity, and returns that identity to every page in the application. Every +page.server.ts that needs authentication can then read the resolved user from the parent layout data rather than re-reading the cookie themselves.
The pattern sounds straightforward, but it involves four distinct concerns that each need careful handling:
Reading cookies correctly means understanding the cookies object on the load event, which cookies are accessible from which routes, and what happens when a cookie is absent.
Forwarding credentials from server load functions to internal API endpoints involves SvelteKit’s event.fetch, which automatically carries cookies to same-origin requests. The rules that govern which cookies are forwarded and to which origins matter for applications that span multiple domains or subdomains.
Setting cookies in response to a server load requires cookies.set(), not a response header. There is a specific reason for this restriction, and understanding it prevents a subtle class of bugs.
Setting response headers with setHeaders controls how the browser and any intermediary caches treat the response. It has a meaningful effect during SSR and a different, more limited effect during client-side navigation. Knowing the difference is what makes setHeaders useful rather than confusing.
The cookies Object vs setHeaders
Server load functions receive a load event object. The cookies property on that object is not a raw HTTP header parser. It is a structured API that SvelteKit builds from the incoming Cookie request header and maintains across the lifetime of the request.
When you call cookies.get('session'), SvelteKit parses the cookie string, finds the named cookie, and returns its value. When you call cookies.set('session', value, options), SvelteKit queues a Set-Cookie response header and also updates its internal cookie state so that subsequent reads within the same request reflect the new value. When you call cookies.delete('session'), it queues a header that expires the cookie immediately and removes it from the internal state.
This stateful model is intentional. A single request might involve reading a session cookie, determining it has expired, deleting the old cookie, and setting a refreshed one. The cookies object maintains consistency across all of those operations without you having to track the internal state manually.
The setHeaders function is a separate tool with a narrower purpose. It sets response headers other than Set-Cookie for the current request. It affects the HTTP response the browser or cache receives, not the data that flows into the page component.
Implementation
Step 1: Reading Cookies in a Server Load Function
The cookies object is available on the load event in +page.server.ts and +layout.server.ts. It is not available in universal load functions (+page.ts, +layout.ts) because those can run in the browser, where raw cookie access is governed by JavaScript’s document.cookie API rather than HTTP headers.
The most important use of cookies.get() in a typical application is reading the session identifier in the root layout server load. This is the natural place for it because the root layout wraps every page. Any user identity resolved here flows down to every page in the route hierarchy through SvelteKit’s data merging.
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ cookies, fetch }) => {
const sessionId = cookies.get('session_id')
// If no session cookie exists, the user is not logged in.
// Return a null user. Pages that require authentication will
// redirect from their own load functions when they see null here.
if (!sessionId) {
return { user: null }
}
// Validate the session against the API. event.fetch automatically
// forwards the original request's cookies to same-origin calls,
// but here we are being explicit by passing the session ID in a
// header, which works reliably regardless of cookie forwarding rules.
const response = await fetch('/api/session/validate', {
headers: {
'X-Session-Id': sessionId
}
})
if (!response.ok) {
// Session invalid or expired. Return null so pages can react.
return { user: null }
}
const user = await response.json()
return { user }
} // src/routes/api/session/validate/+server.ts
import type { RequestHandler } from './$types'
import { json, error } from '@sveltejs/kit'
interface UserRecord {
id: string
email: string
displayName: string
role: 'admin' | 'editor' | 'viewer'
avatarUrl: string | null
}
// In a real application this handler queries whatever session store
// your application uses: Redis, a database table, JWT verification.
// The load function does not care. It calls fetch and reads the result.
export const GET: RequestHandler = async ({ request }) => {
const sessionId = request.headers.get('X-Session-Id')
if (!sessionId) {
error(400, 'Session ID header missing')
}
// Simulated session lookup. Replace this with your actual session store.
if (sessionId !== 'valid-session-token-example') {
error(401, 'Session not found or expired')
}
const user: UserRecord = {
id: 'user-1',
email: 'alex@example.com',
displayName: 'Alex Kim',
role: 'editor',
avatarUrl: '/avatars/user-1.jpg'
}
return json(user)
} The user value returned from the layout load becomes available to every page through the data prop. Pages that require authentication inspect data.user and redirect when it is null.
// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ parent }) => {
// Calling parent() gives this load function access to the layout
// data without re-reading the cookie or re-validating the session.
const { user } = await parent()
if (!user) {
// Redirect to the login page. The 303 status is correct for
// a redirect after a GET request in SvelteKit's redirect helper.
redirect(303, '/login')
}
// Fetch dashboard-specific data now that we know who the user is.
// The user ID is known from the session, not from the URL.
// This prevents users from accessing other users' dashboards
// by changing a route parameter.
const response = await fetch(`/api/users/${user.id}/dashboard`)
if (!response.ok) {
return { stats: null, recentActivity: [] }
}
const dashboardData = await response.json()
return { stats: dashboardData.stats, recentActivity: dashboardData.recentActivity }
} Calling parent() is how a page server load accesses the layout server load’s resolved data. This is the key to the single-read pattern: the root layout reads the cookie once per request, resolves the session once, and every child load function inherits that result.
Step 2: Cookie Forwarding Rules
When a server load function calls event.fetch with a same-origin URL, SvelteKit forwards the cookies from the original incoming request to that fetch call. This is Superpower Two from the previous article. But same-origin means exactly what it says: the same scheme, host, and port.
Consider an application deployed to app.example.com that has an API server at api.example.com. These are different origins even though they share a root domain. Cookies set with Domain=example.com are sent by the browser to both, but SvelteKit’s event.fetch only forwards cookies automatically to requests that match the origin of the incoming request exactly.
// src/routes/reports/+page.server.ts
import type { PageServerLoad } from './$types'
import { INTERNAL_API_SECRET } from '$env/static/private'
export const load: PageServerLoad = async ({ cookies, fetch }) => {
// Same-origin fetch to /api/reports. Cookies forwarded automatically.
// No special handling needed.
const internalResponse = await fetch('/api/reports/summary')
// Cross-origin fetch to api.example.com. No automatic cookie forwarding.
// The session cookie value is read explicitly and forwarded as a header.
const sessionId = cookies.get('session_id')
const externalResponse = await fetch('https://api.example.com/reports/detailed', {
headers: {
// Using a custom header is more explicit and intentional than
// forwarding the cookie string directly in a Cookie header.
// The API on api.example.com is configured to read this header.
'X-Session-Id': sessionId ?? '',
// Internal service-to-service authentication, separate from
// the user session. Never expose this to the browser.
Authorization: `Bearer ${INTERNAL_API_SECRET}`
}
})
if (!internalResponse.ok || !externalResponse.ok) {
return { summary: null, detailed: null }
}
const [summary, detailed] = await Promise.all([internalResponse.json(), externalResponse.json()])
return { summary, detailed }
} The rule to apply consistently: for same-origin internal routes, rely on automatic forwarding. For cross-origin requests, forward credentials explicitly and deliberately. This forces the data flow to be visible in the code rather than implied.
Step 3: Why Set-Cookie Must Go Through cookies.set()
The setHeaders function accepts an object of response headers. It is used for things like Cache-Control, Vary, X-Frame-Options, and any custom header your application needs to send. But it explicitly does not accept Set-Cookie.
Attempting to set a cookie through setHeaders will throw a runtime error:
// Wrong: SvelteKit throws an error if you attempt this.
export const load: PageServerLoad = async ({ setHeaders }) => {
setHeaders({
// This throws: "Use the 'cookies.set()' API instead"
'set-cookie': 'session=abc; Path=/; HttpOnly'
})
return {}
} The reason for this restriction is not arbitrary. Setting cookies through raw header strings means bypassing SvelteKit’s internal cookie state tracking. If the layout load sets a cookie and a page load reads it, SvelteKit needs to know what the current cookie value is at every point in the request lifecycle. That is only possible if all cookie mutations go through the cookies object.
There is also a security consideration. Cookie attributes like HttpOnly, Secure, SameSite, and Path exist to protect cookies from being read by JavaScript, sent over insecure connections, or accessed from unexpected paths. The cookies.set() API enforces a default configuration that makes secure choices unless you explicitly opt out. The raw string approach requires you to remember every attribute every time.
// src/routes/+layout.server.ts
// Demonstrating a session refresh pattern.
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ cookies, fetch }) => {
const sessionId = cookies.get('session_id')
if (!sessionId) {
return { user: null }
}
const response = await fetch('/api/session/validate', {
headers: { 'X-Session-Id': sessionId }
})
if (!response.ok) {
// Session is invalid. Delete the stale cookie so the browser
// stops sending it on future requests.
cookies.delete('session_id', { path: '/' })
return { user: null }
}
const { user, refreshedSessionId } = await response.json()
// If the API issued a new session ID (rolling session pattern),
// update the cookie with the refreshed value.
if (refreshedSessionId && refreshedSessionId !== sessionId) {
cookies.set('session_id', refreshedSessionId, {
// HttpOnly prevents JavaScript from reading the cookie value.
// This is critical: session tokens should never be accessible
// to client-side code because that exposes them to XSS attacks.
httpOnly: true,
// Secure ensures the cookie is only sent over HTTPS.
// In development this can be false, but always true in production.
secure: true,
// Strict prevents the cookie from being sent on cross-site requests.
// This protects against CSRF for most common attack vectors.
sameSite: 'strict',
// The path controls which routes the cookie is sent with.
// '/' means all routes on this domain receive the cookie.
path: '/',
// maxAge sets the cookie lifetime in seconds.
// 7 days expressed in seconds.
maxAge: 60 * 60 * 24 * 7
})
}
return { user }
} The cookies.set() API requires you to specify path explicitly. This is intentional. Without a path, cookie behavior is defined relative to the URL that set the cookie, which produces surprising behavior when a cookie is set deep in the route hierarchy but expected to be readable everywhere. Always pass path: '/' unless you have a specific reason to restrict the cookie to a subtree.
Step 4: Setting Response Headers with setHeaders
The setHeaders function is for every response header that is not Set-Cookie. Its most common use in load functions is cache control, which communicates to the browser and to any CDN or reverse proxy sitting in front of your application how long a response can be reused.
// src/routes/articles/[slug]/+page.server.ts
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, setHeaders, fetch }) => {
const response = await fetch(`/api/articles/${params.slug}`)
if (!response.ok) {
error(404, 'Article not found')
}
const article = await response.json()
// This page's content changes rarely. Tell the browser and any CDN
// that the response can be cached for one hour.
// 'public' means intermediate caches (CDNs, proxies) can also store it.
// 'max-age' is the lifetime in seconds: 3600 = one hour.
// 's-maxage' overrides max-age for shared caches like CDNs.
setHeaders({
'cache-control': 'public, max-age=3600, s-maxage=86400'
})
return { article }
} // src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types'
import { redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ parent, setHeaders, fetch }) => {
const { user } = await parent()
if (!user) {
redirect(303, '/login')
}
const response = await fetch(`/api/users/${user.id}/dashboard`)
const dashboardData = await response.json()
// Dashboard content is user-specific and must not be cached by
// shared caches. 'private' restricts caching to the browser only.
// 'no-store' prevents even the browser from caching it.
// Use this for any page containing sensitive user data.
setHeaders({
'cache-control': 'private, no-store'
})
return dashboardData
} The choice between public and private is not about access control in the SvelteKit sense. It is a cache instruction. A public response tells intermediaries they are allowed to cache and serve it to any user. A private response tells them to keep it for the single browser that requested it. If a page contains any user-specific data, it must be private.
Step 5: What setHeaders Does During SSR vs Client Navigation
This is the part of setHeaders that surprises developers. Its effect is asymmetric depending on when the load function runs.
During an SSR request, setHeaders sets headers on the actual HTTP response sent to the browser. A Cache-Control: public, max-age=3600 header means the browser and any CDN between the user and the server will cache the rendered HTML. The next time the same URL is requested, it may be served from cache without the server running any load functions at all.
During a client-side navigation (when the user clicks a link in a running SvelteKit app), there is no HTTP response. The browser is already running JavaScript and SvelteKit handles the navigation internally. The load function runs in the browser, fetches data, and updates the page. setHeaders is called in this context, but it has no HTTP response to attach headers to. SvelteKit ignores the call silently. The cache control headers you set have no effect on client navigations.
The practical implication is that cache control headers set in load functions apply to the full-page render on first visit. They do not affect how SvelteKit manages subsequent client-side navigations to the same route. Those navigations use SvelteKit’s own navigation and invalidation model, which is independent of HTTP caching.
This also means you cannot use setHeaders in a universal load function (+page.ts) with the expectation that it will control browser caching across all navigations. A universal load runs both on the server and in the browser, but setHeaders only does something on the server side. If you need cache behavior for the initial page load, setHeaders is the right tool. If you need to control when SvelteKit re-runs a load function after the initial render, that is handled by invalidate and depends, which is a different mechanism covered in a later article in this series.
Practical Example: Loading a User Session
The following is a complete, integrated example that puts all of the concepts together: a root layout that reads a session cookie, validates it, optionally refreshes it, sets appropriate cache control headers, and makes the resolved user available to every page.
The session types
// src/lib/types/session.ts
export interface SessionUser {
id: string
email: string
displayName: string
role: 'admin' | 'editor' | 'viewer'
avatarUrl: string | null
}
export interface SessionValidationResult {
user: SessionUser
// The API may issue a new session ID when it extends a session.
// If null, the existing session ID is still valid and unchanged.
refreshedSessionId: string | null
} The root layout server load
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'
import type { SessionValidationResult } from '$lib/types/session'
const SESSION_COOKIE = 'sid'
export const load: LayoutServerLoad = async ({ cookies, url, setHeaders, fetch }) => {
const sessionId = cookies.get(SESSION_COOKIE)
// No session cookie present. User is not authenticated.
// Return null without making any network requests.
if (!sessionId) {
return { user: null }
}
const response = await fetch('/api/session/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId })
})
// Invalid or expired session. Remove the stale cookie to prevent
// it from being sent on every subsequent request unnecessarily.
if (!response.ok) {
cookies.delete(SESSION_COOKIE, { path: '/' })
return { user: null }
}
const result: SessionValidationResult = await response.json()
// Rolling session: if the server issued a new session ID, replace
// the old one. This extends the session lifetime on active use.
if (result.refreshedSessionId) {
cookies.set(SESSION_COOKIE, result.refreshedSessionId, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 60 * 60 * 24 * 7
})
}
// The layout response itself is not publicly cacheable because it
// varies by user session. The SSR output for this request contains
// user-specific data embedded in the page.
setHeaders({
'cache-control': 'private, no-cache'
})
return { user: result.user }
} The session validation API route
// src/routes/api/session/validate/+server.ts
import type { RequestHandler } from './$types'
import { json, error } from '@sveltejs/kit'
import type { SessionValidationResult, SessionUser } from '$lib/types/session'
interface ValidateRequestBody {
sessionId: string
}
// A real session store. Here simulated with a static map.
// In production, this would query Redis, a database table, or verify a JWT.
const SESSION_STORE = new Map<string, { user: SessionUser; createdAt: number }>([
[
'valid-session-token-example',
{
user: {
id: 'user-1',
email: 'alex@example.com',
displayName: 'Alex Kim',
role: 'editor',
avatarUrl: '/avatars/user-1.jpg'
},
createdAt: Date.now()
}
]
])
const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const SESSION_REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000 // refresh if less than 1 day left
export const POST: RequestHandler = async ({ request }) => {
let body: ValidateRequestBody
try {
body = await request.json()
} catch {
error(400, 'Invalid request body')
}
const record = SESSION_STORE.get(body.sessionId)
if (!record) {
error(401, 'Session not found')
}
const age = Date.now() - record.createdAt
if (age > SESSION_LIFETIME_MS) {
SESSION_STORE.delete(body.sessionId)
error(401, 'Session expired')
}
// Issue a new session ID if the session is approaching expiry.
let refreshedSessionId: string | null = null
if (age > SESSION_LIFETIME_MS - SESSION_REFRESH_THRESHOLD_MS) {
refreshedSessionId = crypto.randomUUID()
SESSION_STORE.set(refreshedSessionId, {
user: record.user,
createdAt: Date.now()
})
SESSION_STORE.delete(body.sessionId)
}
const result: SessionValidationResult = {
user: record.user,
refreshedSessionId
}
return json(result)
} Protected page load
// src/routes/account/+page.server.ts
import type { PageServerLoad } from './$types'
import { redirect, error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ parent, setHeaders, fetch }) => {
// parent() reads the already-resolved layout data.
// This does not re-validate the session. The cookie was read once
// by the layout and the result is reused here.
const { user } = await parent()
if (!user) {
redirect(303, '/login?returnTo=/account')
}
const response = await fetch(`/api/users/${user.id}/account`)
if (!response.ok) {
error(500, 'Failed to load account data')
}
const account = await response.json()
// Account data is personal. Prevent all caching.
setHeaders({
'cache-control': 'private, no-store'
})
return { account }
} The root layout component
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import type { LayoutData } from './$types'
let { data, children }: { data: LayoutData; children: import('svelte').Snippet } = $props()
// data.user is null when not logged in, or a SessionUser when authenticated.
// $derived makes the boolean reactive so the nav updates if the user
// state changes within the same page session (e.g., after login).
const isAuthenticated = $derived(data.user !== null)
</script>
<header>
<nav>
<a href="/">Home</a>
<a href="/articles">Articles</a>
{#if isAuthenticated}
<a href="/account">{data.user?.displayName}</a>
<form method="POST" action="/api/auth/logout">
<button type="submit">Log out</button>
</form>
{:else}
<a href="/login">Log in</a>
{/if}
</nav>
</header>
<main>
{@render children()}
</main> The component uses $derived to compute isAuthenticated from the data.user value. This keeps the reactivity clean: if the user data ever changes in the same application session (for example, after a client-side login or logout action triggers invalidate), the derived value updates and the navigation reflects the new state without any explicit imperative update.
Common Mistakes and Anti-Patterns
Reading cookies in a universal load function
// Wrong: cookies is not available in +page.ts
// This file can run in the browser where the cookies object does not exist.
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ cookies }) => {
// TypeScript will catch this: 'cookies' does not exist on PageLoadEvent.
// But even if it did, the browser has no equivalent API here.
const sessionId = cookies.get('session_id')
return {}
} // Correct: use +page.server.ts for any cookie access.
// The .server suffix guarantees the file never runs in the browser.
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ cookies }) => {
const sessionId = cookies.get('session_id')
return {}
} TypeScript will catch this mistake at compile time because the type of the load event in +page.ts does not include a cookies property. But the reason to understand is that universal load functions are designed to run on both server and client, and there is no unified cookie API that works identically in both environments.
Attempting to set cookies with setHeaders
// Wrong: setHeaders does not accept Set-Cookie.
// SvelteKit throws a runtime error.
export const load: PageServerLoad = async ({ setHeaders }) => {
setHeaders({
'set-cookie': 'session_id=abc; HttpOnly; Secure; SameSite=Strict; Path=/'
})
return {}
} // Correct: all cookie mutations go through cookies.set().
export const load: PageServerLoad = async ({ cookies }) => {
cookies.set('session_id', 'abc', {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/'
})
return {}
} Beyond the runtime error, the raw header string approach skips SvelteKit’s internal cookie state tracking. Any subsequent call to cookies.get('session_id') within the same request lifecycle would not see the new value because the cookies object was never notified of the change.
Setting conflicting headers across layout and page load functions
// src/routes/+layout.server.ts
export const load: LayoutServerLoad = async ({ setHeaders }) => {
// Layout sets one cache directive.
setHeaders({ 'cache-control': 'public, max-age=3600' })
return {}
} // src/routes/blog/[slug]/+page.server.ts
export const load: PageServerLoad = async ({ setHeaders }) => {
// Page tries to set a different cache directive.
// SvelteKit throws: "conflicting values for header 'cache-control'"
setHeaders({ 'cache-control': 'private, no-store' })
return {}
} When layout and page load functions run for the same request, SvelteKit merges their setHeaders calls. If both try to set the same header with different values, SvelteKit throws. The resolution is to set cache-control only in the most specific load function for a given route: the page server load. The layout should not set cache headers if individual pages need to control them independently.
// Correct: only the page load sets cache-control.
// The layout does not touch it.
export const load: PageServerLoad = async ({ setHeaders, parent }) => {
const { user } = await parent()
setHeaders({
'cache-control': user ? 'private, no-store' : 'public, max-age=300'
})
return {}
} Forgetting to specify path when deleting a cookie
// Wrong: cookies.delete requires a path option.
// Without it, SvelteKit throws an error.
export const load: PageServerLoad = async ({ cookies }) => {
cookies.delete('session_id')
return { user: null }
} // Correct: always provide path when calling cookies.delete().
// The path must match the path used when the cookie was set.
export const load: PageServerLoad = async ({ cookies }) => {
cookies.delete('session_id', { path: '/' })
return { user: null }
} The path option is required by SvelteKit’s API to prevent ambiguity. A cookie set with Path=/ and a cookie set with Path=/admin are distinct cookies even if they share the same name. Specifying the path explicitly ensures you are deleting the right one.
Performance and Scaling Considerations
The root layout server load runs on every server-rendered request. In an authenticated application, that means the session validation fetch runs on every first visit to every page. Keeping this path fast is one of the highest-leverage optimizations available.
The session validation route should be as lightweight as possible. A lookup by session ID against an in-memory store like Redis is a sub-millisecond operation. A database query with no index can be tens of milliseconds. The difference is negligible on its own but compounds across thousands of concurrent users and becomes the rate-limiting factor in your server’s throughput.
For applications where most pages are publicly accessible, the conditional check at the top of the layout load is important:
export const load: LayoutServerLoad = async ({ cookies, fetch }) => {
const sessionId = cookies.get('session_id')
// Exit immediately if there is no session to validate.
// Unauthenticated users pay zero API cost on the layout load.
if (!sessionId) {
return { user: null }
}
// Only users with a session cookie reach this fetch call.
const response = await fetch('/api/session/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId })
})
// ...rest of validation
} For pages that are publicly cacheable, setHeaders with appropriate Cache-Control and Vary directives allows CDNs to serve the page without hitting your origin server at all. A blog article page, a documentation page, or a marketing landing page can be cached at the edge with a reasonable TTL and the origin load function runs once per cache entry rather than once per user visit.
// src/routes/docs/[...path]/+page.server.ts
import type { PageServerLoad } from './$types'
import { error } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, setHeaders, fetch }) => {
const path = params.path.join('/')
const response = await fetch(`/api/docs?path=${encodeURIComponent(path)}`)
if (!response.ok) {
error(404, 'Documentation page not found')
}
const doc = await response.json()
// Documentation pages are public and change infrequently.
// CDNs can cache them for one hour (s-maxage).
// Browsers revalidate after five minutes (max-age).
// The Vary header tells caches that the response depends on
// Accept-Encoding (for compression), which is standard practice.
setHeaders({
'cache-control': 'public, max-age=300, s-maxage=3600',
vary: 'Accept-Encoding'
})
return { doc }
} When NOT to Use This Pattern
The root layout session pattern described in this article is the right approach for applications where most or all pages share a concept of the current user. But not every application has that structure.
Purely public sites with no authentication at all have no use for session cookies in load functions. If your blog, documentation site, or marketing site has no login, there is nothing to read and no cookies to manage.
API-only backends built with SvelteKit’s +server.ts routes exclusively are not using page load functions at all. Cookie handling for those is done directly in the request handler, not through load function APIs.
Layouts that are too narrow for session reads can create unnecessary coupling. If you have an authenticated section of your site at /app and a public section at / and the two never share a common authenticated layout, reading the session in the root layout forces a cookie read on every page including purely public pages where authentication is irrelevant. In that case, a dedicated layout at src/routes/app/+layout.server.ts is more appropriate than placing the session logic at the root.
High-frequency polling or real-time pages should not rely on load functions for data freshness. Load functions run on navigation, not on a timer. If a page needs data that updates every few seconds, the component handles the refresh with a $effect that sets up a polling interval or a WebSocket connection. The load function provides the initial snapshot and the component keeps it current.
Conclusion
Cookies and response headers in server load functions are not complicated, but they have rules that matter. Cookies belong to +page.server.ts and +layout.server.ts exclusively because those are the only contexts guaranteed to run on the server. The cookies object is stateful within a request, and all cookie mutations must go through it because SvelteKit tracks that state internally. Setting a cookie through setHeaders bypasses that tracking and throws an error by design.
The setHeaders function sets HTTP response headers for the SSR response. Cache-Control headers set there instruct browsers and CDNs how to cache the rendered page. During client-side navigations those headers have no effect because there is no HTTP response to attach them to.
The root layout session pattern, reading the session cookie once and making the resolved user available to all pages through SvelteKit’s data merging, is the correct architectural foundation for authentication. It keeps cookie reads in one place, avoids redundant validation, and makes the user identity available without prop drilling through every layout and page in the hierarchy.
Key Takeaways
Key takeaways:
- Cookies are accessible only in
+page.server.tsand+layout.server.ts. Universal load functions cannot access them. - All cookie mutations, including setting and deleting, must go through the
cookiesobject. UsingsetHeadersforSet-Cookiethrows a runtime error. cookies.set()requires an explicitpathoption. Always usepath: '/'unless you have a reason to restrict the cookie to a subtree.- SvelteKit’s
event.fetchforwards cookies automatically to same-origin requests. Cross-origin requests require explicit credential forwarding. setHeaderssets HTTP response headers for the SSR response. It has no effect during client-side navigations.- Use
Cache-Control: private, no-storefor pages containing user-specific data andpublic, max-age=Nfor publicly cacheable pages. - Setting the same header in both layout and page server loads throws a conflict error. Assign cache control responsibility to the most specific load function for each route.
- Read the session cookie in the root layout server load exactly once per request and use
parent()in page loads to access the resolved user without re-reading the cookie.
What’s Next
Cookies and headers give you access to request and response metadata inside server load functions. But there is a broader question: where does request-scoped data like a validated session actually live before it reaches a load function, and how is it put there in the first place? The answer is event.locals, populated by hooks.server.ts. The next article, locals and the Hooks Lifecycle, covers how the handle hook works, how to type and populate locals, and how that data flows into every server load function in your application.
Further Reading
- SvelteKit Cookies Documentation — official reference for the cookies API on the load event
- SvelteKit Load Documentation — official reference for
cookiesandsetHeadersin load functions