This skill covers the three pillars of the SvelteKit data layer: (1) Loading data (+page.js / +page.server.js / +layout.js / +layout.server.js of load function,page.data, URL data, cookies/headers, errors and redirects, streaming responses, dependency tracking and manual invalidation), (2) Form actions (default/named actions,fail validation,use:enhance progressive enhancement,GET vs POST),(3) Page options(prerender / entries / ssr / csr / trailingSlash / config)。
When to use this skill
- decide the trade-off of
+page.jsvs+page.server.js(private credentials, serialization, combined use) - in
loadfunction useparams/route/url/fetch/parent/depends/untrack - use
page.datato access child page data in a parent layout - use
error(status, msg)/redirect(status, location)to terminate load/action - use returning a Promise to implement a streaming skeleton screen
- solve "when does the load function re-run" and how to use
invalidate/invalidateAll/untrackfor precise control - in server
load/ action readcookies/ set response headers - design
+page.server.jsactions: default action, named action,?/namequery parameters,fail()validation - use
use:enhanceto progressively enhance forms, backfillformfields, prevent default reset, customizeapplyAction - decide which routes
prerender = true/entries()/ssr = false/csr = false/trailingSlash
Critical: Loading data (universal vs server load functions)
SvelteKit has two kinds of load functions, which run in different locations and have different constraints.
Universal load(+page.js / +layout.js): by default during SSR it runs once on the server, then runs again during client hydration; after that all navigation runs in the browser. It can returnany JS value(including non-serializable objects such as Svelte component constructors).fetch is inlined into HTML during the SSR phase, reused during hydration, and will not leak private credentials to the client.
Server load(+page.server.js / +layout.server.js):always runs on the server. The return value must be serialized with devalue (JSON + BigInt / Date / Map / Set / RegExp / circular references). Can access cookies / locals / request / clientAddress / platform。
// +page.server.js
import * as db from '$lib/server/database';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params }) {
return { post: await db.getPost(params.slug) };
}
// +page.js —— 公共 API fetch,浏览器可直接拉
/** @type {import('./$types').PageLoad} */
export async function load({ fetch, params }) {
const res = await fetch(`/api/items/${params.id}`);
return { item: await res.json() };
}
Both can exist at the same time. When both exist, server load runs first, and its return value becomes the data parameter of universal load; only the return value of universal load reaches the page:
// +page.server.js —— 仅在服务端,返回 sessionId
export async function load({ locals }) {
return { sessionId: locals.sessionId };
}
// +page.js —— 浏览器也能跑,但能拿到服务端上下文
export async function load({ data, fetch }) {
const res = await fetch(`/api/me`, { headers: { 'x-session': data.sessionId } });
return { me: await res.json() };
}
When to use which:
- Need private environment variables, database, file system → server load
- Data is a public API, and you want to reduce server round trips → universal load
- Need to return a non-serializable object (Svelte component class) → universal load
- Need streaming (slow data + skeleton screen) → must be server load(a Promise from universal load will not be streamed)
- Need to be able to re-run on the client too → universal load
Critical: page.data (sharing data across components)
The page and all ancestor layouts each havetheir own data prop, containing the merged data of itself + all ancestors. Whenparentcomponent needschildcomponent's returned data, use $app/state of page.data($app/stores of $page is the old-style equivalent):
<!-- src/routes/+layout.svelte -->
<script>
import { page } from '$app/state';
/** @type {import('./$types').LayoutProps} */
let { data, children } = $props();
</script>
<svelte:head>
<title>{page.data.title}</title>
</svelte:head>
{@render children()}
Merge rule: when the same key appears at the same levelthe latter overrides。+layout.js returns { a:1, b:2 } + +page.js returns { b:3, c:4 } → data = { a:1, b:3, c:4 }。
Critical: URL data (params/route/url)
load function via url / route / params Access the URL.url.hash Not available during SSR (no fragment on the server).
// src/routes/a/[b]/[...c]/+page.js
/** @type {import('./$types').PageLoad} */
export function load({ url, route, params }) {
// route.id = '/a/[b]/[...c]'
// url = URL 实例(origin/pathname/searchParams/...)
// params.b = 'x' params.c = 'y/z'
return { q: url.searchParams.get('q') };
}
url.searchParams.get/getAll/has In dependency tracking it is anindependentkey——?x=1&y=1 → ?x=1&y=2 will notrerun a load that only depends on y .
Critical: Cookies and Headers
Only server load can read / write cookies。setHeaders takes effect when called during SSR in universal load; calling it in the browser is a no-op.
// +layout.server.js
export async function load({ cookies }) {
const sessionid = cookies.get('sessionid');
return { user: await db.getUser(sessionid) };
}
// +page.js —— 转发上游 cache 头
export async function load({ fetch, setHeaders }) {
const response = await fetch('https://cms.example.com/products.json');
setHeaders({ 'cache-control': response.headers.get('cache-control') });
return response.json();
}
Constraints:setHeaders a header with the same name can only be set once; you cannot use setHeaders to set set-cookie(use cookies.set);fetch only onsame-origin or subdomaintargets with cookies; for other domains use handleFetch hook。
Critical: Errors and Redirects
error(status, message) and redirect(status, location) boththrow directly——do not throwyourself (SvelteKit 1.x behavior is deprecated).redirect Do not put it in a try block(it will be swallowed by catch).
import { error, redirect } from '@sveltejs/kit';
export function load({ locals }) {
if (!locals.user) error(401, 'not logged in');
if (!locals.user.isAdmin) error(403, 'not an admin');
}
export function load({ locals, url }) {
if (!locals.user) {
const next = url.pathname + url.search;
redirect(303, `/login?redirectTo=${encodeURIComponent(next)}`);
}
}
expected error(throw with error() ) shows the nearest +error.svelte with the correct status;unexpected error triggers the handleError hook and is handled as 500.
For browser-side navigation use $app/navigation of goto:
import { goto } from '$app/navigation';
goto('/login');
Critical: Streaming with promises
Server load returnsan un-awaited Promise will be streamed to the browser, allowing fast data to render first and slow data to arrive later.
// +page.server.js —— 关键数据先 await,慢数据后流
export async function load({ params }) {
return {
post: await loadPost(params.slug),
comments: loadComments(params.slug) // 不 await → 单独流
};
}
<!-- +page.svelte -->
<h1>{data.post.title}</h1>
{#await data.comments}
<p>Loading comments...</p>
{:then comments}
{#each comments as c}<p>{c.content}</p>{/each}
{:catch error}
<p>error: {error.message}</p>
{/await}
Hard constraint:
- streaming only takes effect when JS is enabled and not on buffering platforms such as Lambda/Firebase
- After the response starts streamingyou cannotthen
setHeaders/redirect - manually add
.catch(() => {})to a Promise to prevent unhandled rejection;fetchis handled automatically by SvelteKit - A
params.xinside a nested promise will not be dependency-tracked——it must be accessed at the top-level body
Critical: When load functions rerun
SvelteKit tracks each load's dependencies to avoid rerunning.load Conditions for re-execution:
- Access
paramsa property and its value changed - Access
url.pathname/url.searchetc. and the value changed url.searchParams.get/getAll/hasthe corresponding parameter changedawait parent()and the parent load reran- Declare a dependency via
fetch(url)ordepends(url), andinvalidate(url)is called invalidateAll()force rerun all active loads
// +page.js —— 自定义依赖标签(约定 [a-z]: 前缀)
export async function load({ fetch, depends }) {
depends('app:random');
const r = await fetch('https://api.example.com/random-number');
return { number: await r.json() };
}
<script>
import { invalidate, invalidateAll } from '$app/navigation';
function rerun() {
invalidate('app:random');
invalidate('https://api.example.com/random-number');
invalidate(url => url.href.includes('random-number'));
invalidateAll();
}
</script>
<button onclick={rerun}>refresh</button>
server load does notautomatically depend on the fetched URL (to avoid leaking credentials)——you must depends(url) declare it explicitly. Use untrack(fn) to exclude a dependency:
export async function load({ untrack, url }) {
if (untrack(() => url.pathname === '/')) return { message: 'Welcome!' };
}
Rerun ≠ rebuild component。+layout.svelte / +page.svelte instance is preserved, only the data prop updates, and the component's internal state is preserved. To force a rebuild → use {#key page.url.pathname}。
Critical: Form actions (default/named, validation, redirects)
+page.server.js export actions object to provide <form> endpoints.action is always POST(GET should not have side effects).
Default action:
// src/routes/login/+page.server.js
/** @satisfies {import('./$types').Actions} */
export const actions = {
default: async (event) => { /* ... */ }
};
<form method="POST">
<input name="email">
<input name="password" type="password">
<button>Log in</button>
</form>
Named actions: use ?/name to distinguish:
export const actions = {
login: async (event) => { /* ... */ },
register: async (event) => { /* ... */ }
};
<form method="POST" action="?/login">...</form>
<form method="POST" action="/login?/register">...</form> <!-- 跨页调用 -->
<!-- 同表单不同按钮 -->
<form method="POST" action="?/login">
<button>Login</button>
<button formaction="?/register">Register</button>
</form>
Important: default + named cannot coexist——if a named POST action does not redirect,?/name stays in the URL, and the next default POST will also hit it.
Validation errors:fail(status, data) returns status + data, and data goes into form prop / page.form / page.status:
import { fail } from '@sveltejs/kit';
export const actions = {
login: async ({ cookies, request }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
if (!email) return fail(400, { email, missing: true });
const user = await db.getUser(email);
if (!user || user.password !== db.hash(password)) {
return fail(400, { email, incorrect: true });
}
cookies.set('sessionid', await db.createSession(user), { path: '/' });
return { success: true };
}
};
Anatomy: action receives RequestEvent, can read request.formData(), can write cookies, can return / fail / redirect / error. The return value goes into the form prop, onlyfor this responsevalid (disappears on reload).Safe for repopulation— Only echo fields the user permits,neverecho back passwords.
Redirects: same as load, use redirect(status, location)。
After Action load: after the action completes (unless redirect / unexpected error) the page re-renders—load the function re-runs.handle hook does notre-run—if in handle you read locals.userfrom a cookie, after the action modifies the cookie you mustmanually update event.locals。
Critical: use:enhance (progressive enhancement)
use:enhance is the action for <form> , giving the form the ability to "work without JS, and be smoother with JS".Onlyuse for method="POST" + +page.server.js actions; using it for +server.js or GET will error.
Minimal usage:
<script>
import { enhance } from '$app/forms';
/** @type {import('./$types').PageProps} */
let { form } = $props();
</script>
<form method="POST" use:enhance>
<input name="email" value={form?.email ?? ''}>
</form>
Default behavior: mimics the browser's native behavior but avoids full-page refresh—updates form / page.form / page.status(onlysame-page actions, otherwise it will not update), reset <form>, on success invalidateAll, on redirect goto, on error renders the most recent +error.svelte, resets focus.
Custom SubmitFunction(returning a callback overrides the default post-submit behavior; to restore it use update() or applyAction(result)):
<script>
import { enhance, applyAction } from '$app/forms';
import { goto } from '$app/navigation';
let submitting = $state(false);
/** @type {import('./$types').PageProps} */
let { form } = $props();
</script>
<form
method="POST"
use:enhance={({ formElement, formData, action, cancel, submitter }) => {
submitting = true;
return async ({ result, update }) => {
submitting = false;
if (result.type === 'redirect') {
goto(result.location, { invalidateAll: true });
} else {
await applyAction(result); // 等价于默认 success/failure/redirect/error 行为
}
};
}}
>
<button disabled={submitting}>Save</button>
</form>
Fully handwritten(without use:enhance): use the submit event + fetch + deserialize(cannotuse JSON.parse, because the result contains Date / BigInt):
<script>
import { invalidateAll, goto } from '$app/navigation';
import { applyAction, deserialize } from '$app/forms';
/** @param {SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }} e */
async function handleSubmit(e) {
e.preventDefault();
const data = new FormData(e.currentTarget, e.submitter);
const response = await fetch(e.currentTarget.action, {
method: 'POST', body: data,
headers: { 'x-sveltekit-action': 'true' } // 同名 +server.js 时强制走 action
});
const result = deserialize(await response.text());
if (result.type === 'success') await invalidateAll();
applyAction(result);
}
</script>
<form method="POST" onsubmit={handleSubmit}>...</form>
When the same route has +server.js ,,fetch defaults to +server.js. To force POST to the action you must add the header x-sveltekit-action: true。
Critical: Page options (prerender/entries/ssr/csr/trailingSlash)
Page options control how the entire page (or a subtree) renders. Export from +page.js / +page.server.js / +layout.{js,server.js} .Child overrides parent—you can enable prerender in the root layout and disable it for individual pages.
// +page.js / +layout.js / +page.server.js
export const prerender = true; // 构建时生成 HTML
export const prerender = false; // 显式禁用(用于根 layout 开启全部 prerender 的场景)
export const prerender = 'auto'; // 可 prerender 也可 SSR(不写入 manifest 排除)
export const ssr = false; // 仅 CSR——空 shell
export const csr = false; // 不发任何 JS
export const trailingSlash = 'always' | 'never' | 'ignore';
// entries —— 动态路由告诉 prerender 哪些值
// src/routes/blog/[slug]/+page.server.js
/** @type {import('./$types').EntryGenerator} */
export function entries() {
return [{ slug: 'hello-world' }, { slug: 'another-post' }]; // 可 async
}
// config —— adapter-specific
/** @type {import('some-adapter').Config} */
export const config = { runtime: 'edge' };
prerender: the content is the same for all users (marketing/docs/blog); not applicable to cookies/url.searchParams/user state/form action (POST requires a server). For dynamic routes useentries()orkit.prerender.entries. Error "marked as prerenderable, but were not prerendered" → addentries/ link / change'auto'ssr = false: setting it in the root layout turns the whole app into an SPA.ssr = false+csr = false= render nothing,forbiddencsr = false:<script>is stripped out,<form>is unavailableuse:enhance, links become native browser navigations, HMR stops working. During developmentcsr = dev;keeps HMRtrailingSlash:'never'(default)/about/→ 301 →/about;'always'prerender outputabout/index.html;'ignore'not recommended (breaks SEO)configobjecttop-level merge(no deep merge)—child layouts / pages only override the keys that need changing
Quick Fixes
- Private API exposes credentials → change to
+page.server.jsor a universal relay of non-sensitive fields datapropundefined→ missinglet { data } = $props();or type declaration- Streaming data hydration is lost → a universal load's Promise is not streamed, change to
+page.server.js - During streaming
setHeaderserrors → headers cannot be changed after the response starts streaming fail()then form fields are empty → the second parameter must include the echoed fields ({ email })+value={form?.email ?? ''}- After the action changes the cookie the page still shows the old user →
handleonly runs once, you must manuallyevent.locals.user = ... use:enhancedoes not take effect → it must bemethod="POST"+ POST to+page.server.jsaction- "marked as prerenderable, but not prerendered" → add
entries()orkit.prerender.entries entries()wrong location → must be ina leaf with dynamic parameters(+page.js/+page.server.js/+server.js), not layoutparent()waterfall → do not depend onparent()ofgetData(params)await first, thenawait parent()url.hashundefined during SSR → no fragment on the server; changeonMountread
Gotchas
- Server load return values must be serializable:
Map/Set/Date/BigInt/RegExp/circular references OK (devalue), butmust notreturn a Svelte component class / class instance (unless customized via transport hook) - load functions should be pure—do not share across requests at the top level of
+page.server.jsat the top levellet user(multi-tenant long lifecycle, state will leak) - Dependency tracking only applies to the top-level body: accessing
params.xinside a promise after await will not trigger a rerun (dev warning) - searchParams tracking granularity:
get/getAll/hasindependent;url.searchParamsaccessing as a whole is equivalent tourl.searchtracking the entire string formprop only responds to existence—cleared on refresh (only the receipt of this submission, not persistent data)use:enhancedoes not update across pages by defaultform: when from/aPOST/b?action,/athe form does not update—requiresapplyAction(result)+server.jsconflicts with+page.server.jsof the same name:fetch('/x')defaults to+server.js; POST to an action must addx-sveltekit-action: trueheader+page.jsbridges server load: a missing layout.js is treated as({ data }) => data, automatically passing server load data- streaming + redirect conflict: once the response starts streaming, headers cannot be changed; you cannot
redirect - Lambda/Firebase buffer the entire page—does not hold streaming responses
trailingSlash: 'ignore'breaks SEO:/xand/x/are different URLscsr = falseincompatible with HMR: during developmentcsr = dev;temporarily enabledredirectis caught intry {...}within—directlyredirect()do not wrap in try- echo safety: do not echo passwords/tokens in form data, only echo allowed fields
FAQ
Q: +page.js vs +page.server.js which to choose? A: private credentials/DB/cookies → server; public API/non-serializable objects → universal. Both can exist at the same time, server runs first.
Q: page.data vs data prop? A: data prop = merge of the current component + all ancestors;page.data = data returned by the current page, readable from any ancestor.
Q: How to force load to rerun? A: invalidate(url) precise invalidation (by URL or depends tag), or invalidateAll() full invalidation. In server load, fetch(url) does not automatically depend on the URL, must depends(url)。
Q: After form submission, form prop is gone? A: Normal.form prop exists only while the response exists; it is cleared on refresh. Persistent data should go into the DB and then be read by load .
Q: use:enhance + redirect? A: By default it calls goto(result.location). For customization, check in the callback result.type === 'redirect' then goto(result.location, { invalidateAll: true })。
Q: prerender /blog/[slug] dynamic route? A: Add entries() The function returns a list of slugs, or in svelte.config.js in kit.prerender.entries config.
Q: Turn the entire app into an SPA? A: Root +layout.js set export const ssr = false;(SSG not recommended).
Q: Disable JS? A: export const csr = false;—no hydration,<form> still works (POST full-page refresh).
Q: Submit the same form to different actions? A: <button formaction="?/other"> override <form action>。
Q: Is action data type-safe? A: /** @satisfies {import('./$types').Actions} */ annotation;form prop uses ActionData type.
Q: Can streaming be used on serverless? A: Lambda/Firebase buffer the entire page; NGINX needs to be configured not to buffer.
Q: parent() Synchronous or asynchronous? A: Must await parent()。
Examples & References tables
Key API Overview
| Concept | Key Files / APIs |
|---|---|
| Universal load | +page.js / +layout.js PageLoad / LayoutLoad |
| Server load | +page.server.js / +layout.server.js PageServerLoad / LayoutServerLoad |
| Read data across components | $app/state of page / App.PageData |
| URL data | params / route.id / url(hash SSR unavailable) |
| Cookies / Headers | cookies / setHeaders(server only) |
| Errors/redirects | error() / redirect() / goto |
| Streaming | server load returns a Promise that is not awaited |
| Dependency tracking | depends / untrack / invalidate / invalidateAll |
| Form actions | actions / fail / redirect / form prop |
| Progressive enhancement | enhance / applyAction / deserialize |
| Page options | prerender / entries / ssr / csr / trailingSlash / config |
File Index
| File | Topic | Number of examples |
|---|---|---|
examples/load-functions.md |
Full spectrum of load functions | 17 |
examples/form-actions.md |
form action + use:enhance | 17 |
examples/page-options.md |
Full spectrum of page options | 12 |
| File | Topic |
|---|---|
references/load-functions-reference.md |
load Complete API / parameters / return values / $types |
references/form-actions-reference.md |
action API / use:enhance callbacks / hook integration |
references/page-options-reference.md |
All page options / constraints / behavior |
references/rerunning-loads-reference.md |
When to rerun / manual invalidate / untrack |