Server & API
API endpoints with +server.js
Section titled “API endpoints with +server.js”A +server.js file is a route with no UI — a standalone HTTP endpoint. Export functions named for the HTTP method; each returns a Response. Use it for JSON APIs, webhooks, file downloads, or anything a non-browser client calls.
import { json, error } from '@sveltejs/kit';
/** @param {number} page */async function getPosts(page) { return []; }/** @param {{ title: string }} body */async function createPost(body) { return { id: 1, ...body }; }
export async function GET({ url }) { const page = Number(url.searchParams.get('page') ?? '1'); return json(await getPosts(page)); // json() sets the header + serializes}
export async function POST({ request }) { const body = await request.json(); if (!body.title) error(400, 'title required'); // throw a typed HTTP error return json(await createPost(body), { status: 201 });}Endpoint vs form action: use a form action when a page’s own form submits to the server (it integrates with use:enhance and the form prop). Use a +server.js endpoint for a reusable API consumed by fetch, a mobile app, or a third party.
Hooks: the full surface, verified against the installed Kit
Section titled “Hooks: the full surface, verified against the installed Kit”src/hooks.server.js isn’t limited to handle. Every hook below is a real, currently-exported member of @sveltejs/kit, verified directly against the installed 2.70.3 type definitions (not just docs prose):
| Hook | Verified location (installed Kit 2.70.3) | Purpose |
|---|---|---|
handle | types/index.d.ts:924 doc comment + used everywhere | Runs on every request before the route; set locals, short-circuit, or modify the response |
sequence | types/index.d.ts:3136, export function sequence(...handlers: Handle[]): Handle; | Compose multiple handle functions in order, imported from @sveltejs/kit/hooks |
handleFetch | types/index.d.ts:958 | Rewrites/replaces the result of event.fetch calls made on the server |
handleError | types/index.d.ts:924, 945 | Runs when an unexpected (non-error()/redirect()) exception escapes handle, load, or an action |
reroute | types/index.d.ts:979 | Rewrites the URL used to determine which route matches, before matching happens |
transport | types/index.d.ts:985 | Serializes custom class instances across the server/client boundary (encode/decode pair) |
import { sequence } from '@sveltejs/kit/hooks';
/** @type {import('@sveltejs/kit').Handle} */const addLocalsUser = async ({ event, resolve }) => { event.locals.user = { id: 'u1', name: 'Ava' }; return resolve(event);};
/** @type {import('@sveltejs/kit').Handle} */const logRequest = async ({ event, resolve }) => { const response = await resolve(event); response.headers.set('x-hooks-sequence', 'addLocalsUser,logRequest'); return response;};
export const handle = sequence(addLocalsUser, logRequest);
/** @type {import('@sveltejs/kit').HandleFetch} */export const handleFetch = ({ request, fetch }) => { // e.g. rewrite an internal API host, or forward a cookie to a same-origin call return fetch(request);};
/** @type {import('@sveltejs/kit').HandleServerError} */export const handleError = ({ error, message }) => { console.error('handleError saw:', message); return { message: 'Something went wrong' };};
/** @type {import('@sveltejs/kit').Reroute} */export const reroute = ({ url }) => { if (url.pathname === '/old-path') return '/dashboard'; return url.pathname;};
class Vector { /** @param {number} x @param {number} y */ constructor(x, y) { this.x = x; this.y = y; }}
/** @type {import('@sveltejs/kit').Transport} */export const transport = { Vector: { encode: (value) => value instanceof Vector && [value.x, value.y], decode: (/** @type {[number, number]} */ [x, y]) => new Vector(x, y), },};sequence composing two handle functions is provable from the response itself — every hook it composed left its mark on a header:
$ curl -sI http://localhost:5196/ | grep -i x-hooks-sequencex-hooks-sequence: addLocalsUser,logRequestWhatever you set on event.locals is typed via App.Locals in src/app.d.ts — declaring it once gives every server load, action, and +server.js file autocomplete and type-checking for locals, instead of implicit any:
declare global { namespace App { interface Locals { user: { id: string; name: string } | null; } }}export {};flowchart LR req["incoming request"] --> handle["hooks: handle() set locals.user"] handle --> route["route: load / action / +server"] route --> resp["response"]
A classic bug: throw redirect()/error() swallowed by try/catch
Section titled “A classic bug: throw redirect()/error() swallowed by try/catch”redirect() and error() work by throwing a special, recognized object that SvelteKit’s own machinery catches higher up. A try/catch written to handle a different kind of failure (a flaky fetch, say) can accidentally catch that special throw too — silently turning an intended redirect into a normal 200 response.
// src/routes/catch-bug/+page.server.js — BUG: a broad try/catch swallows the thrown redirectimport { redirect } from '@sveltejs/kit';
async function riskyFetch() { return { ok: true };}
export const load = async () => { try { await riskyFetch(); redirect(303, '/dashboard'); // throws — but the catch below swallows it } catch (e) { console.error('load failed', e); return { fallback: true }; // 200 with fallback data instead of a 303 }};Proven against a real running app — the request that should redirect returns 200 with the fallback data instead:
$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:5195/catch-bugHTTP 200$ curl -s http://localhost:5195/catch-bug | grep -o '<h1[^<]*</h1>'<h1 data-testid="fallback">fallback=true</h1>The fix: check whether the caught value is a redirect or HTTP error before treating it as a real failure, using isRedirect/isHttpError from @sveltejs/kit, and rethrow it if so:
// src/routes/catch-fixed/+page.server.js — FIX: rethrow redirects/HTTP errors, only catch real failuresimport { redirect, isRedirect, isHttpError } from '@sveltejs/kit';
async function riskyFetch() { return { ok: true };}
export const load = async () => { try { await riskyFetch(); redirect(303, '/dashboard'); } catch (e) { if (isRedirect(e) || isHttpError(e)) throw e; // let SvelteKit's control-flow throws through console.error('load failed', e); return { fallback: true }; }};Proven fixed against the same running app — now a real 303:
$ curl -s -D - -o /dev/null http://localhost:5195/catch-fixed | grep -i "^\(HTTP\|location\)"HTTP/1.1 303 See Otherlocation: /dashboardThe lesson generalizes beyond redirect/error: any try/catch wrapping code that might call SvelteKit’s own throwing control-flow functions needs to distinguish “SvelteKit’s own signal” from “an actual error,” or it silently defeats the redirect/error entirely — with no compile-time warning, since both are just JavaScript throws.
$server.ts endpoints vs actions, and $app/navigation
Section titled “$server.ts endpoints vs actions, and $app/navigation”The endpoint-vs-action rule of thumb from earlier still holds: a page’s own form → an action; a reusable, non-form-shaped API → +server.js. On the client, beyond goto() (routing lesson), $app/navigation also exports invalidate()/invalidateAll() (loading-data lesson) and lifecycle hooks around navigation itself:
<script> import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
beforeNavigate(({ to, cancel }) => { if (formIsDirty() && !confirm('Discard changes?')) cancel(); });
afterNavigate(() => { document.getElementById('main')?.focus(); // e.g. reset focus for a11y after nav });</script>Render mode per route, and adapters
Section titled “Render mode per route, and adapters”Each route can choose how it renders, with page options exported from +page.js/+page.server.js:
export const prerender = true— render to a static file at build time (great for pages that are the same for everyone). A layout’sprerendersetting also accepts anentriesarray (or a function returning one) telling SvelteKit which dynamic-route values to crawl and prerender, for routes it can’t discover just by following links.export const ssr = false— skip server rendering (client-only page).export const csr = false— ship no client JS (fully static output for that page).
Finally, an adapter (passed to the sveltekit() Vite plugin, or svelte.config.js’s kit.adapter) packages your built app for a target platform. Versions actually installable today (npm view <pkg> version):
| Adapter | Version | Target |
|---|---|---|
@sveltejs/adapter-auto | 5.5.7 | Detects the platform (Vercel, Netlify, Cloudflare, …) automatically |
@sveltejs/adapter-node | 7.2.9 | A standalone Node server |
@sveltejs/adapter-cloudflare | 6.3.4 | A Cloudflare Worker/Pages |
@sveltejs/adapter-static | 3.0.10 | A fully prerendered static site (no server needed) |
Environment variables: static vs dynamic, and the ORIGIN check
Section titled “Environment variables: static vs dynamic, and the ORIGIN check”Four $env/* modules exist, split on two axes: static (inlined as literals at build time) vs dynamic (read from process.env at request time), and private (server-only) vs public (must be prefixed PUBLIC_, ships to the client). Proving the static-vs-dynamic distinction requires actually inspecting the build output, not just reading docs — so here it is, from a real adapter-node build:
// src/routes/env-test/+page.server.js — illustrative (the proof below is a real build/run, not this harness's shared probe, which has no .env)import { SECRET_API_KEY } from '$env/static/private';import { env as dynamicPrivateEnv } from '$env/dynamic/private';import { PUBLIC_APP_NAME } from '$env/static/public';
export const load = async () => { return { staticPrivate: SECRET_API_KEY, dynamicPrivate: dynamicPrivateEnv.SECRET_API_KEY, staticPublic: PUBLIC_APP_NAME, };};Built with SECRET_API_KEY=build-time-secret-abc123 in .env, the static import is baked directly into the compiled server bundle as a literal:
$ grep -n "SECRET_API_KEY" build/server/chunks/entries/pages/env-test/*.jsvar SECRET_API_KEY = "build-time-secret-abc123";Now running that same build with a different value of the same variable at runtime:
$ PORT=5194 SECRET_API_KEY="runtime-value-xyz789" node build/index.js &$ curl -s http://localhost:5194/env-test<pre data-testid="env-out">static=build-time-secret-abc123 dynamic=runtime-value-xyz789 public=ProbeApp</pre>static is unchanged — it’s the value from the machine that ran npm run build, permanently baked in — while dynamic picked up the new runtime value immediately. This is the reason $env/dynamic/private exists: any value that needs to differ between the build machine and the running container (a database URL, a per-environment API key) must be dynamic, or a value baked in at CI time will silently follow the app to every deployment.
Content-Security-Policy
Section titled “Content-Security-Policy”kit.csp (also passed flat into the sveltekit() Vite plugin config) generates a real Content-Security-Policy header, adding a nonce automatically to every inline script/style SvelteKit itself generates:
sveltekit({ csp: { mode: 'auto', directives: { 'script-src': ['self'] }, },});$ curl -sI http://localhost:5196/ | grep -i content-security-policycontent-security-policy: script-src 'self' 'nonce-34JVJjb2gAt2rFP7KvAHEA=='A fresh nonce is generated per request (verified: it changes across the two curl runs shown in this lesson) — mode: 'auto' uses nonces for dynamically-rendered pages like this one, and hashes instead for prerendered pages (a nonce on a prerendered page would be identical, and therefore useless, on every request).
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated server/hooks/deployment code, check for:
- Does a
try/catchwrap a call toredirect()/error()without checkingisRedirect/isHttpErrorbefore treating the catch as a real failure? Proven above with realcurloutput: a200where a303was intended, with no compile-time signal that anything is wrong. - Does
event.localsget used with noApp.Localsinterface declared insrc/app.d.ts— silently typedanyeverywhere it’s read? - Does a secret read via
$env/static/privateget treated as something that can differ per-deployment without a rebuild? Proven above: it’s baked into the JS bundle at build time — only$env/dynamic/privatereads live. - Is a
PUBLIC_-prefixed variable used for something that shouldn’t reach the client (a real secret, accidentally prefixed)? The prefix is the only thing that decides public-vs-private — a naming mistake, not a runtime check, is what leaks it. - Does
handleget written as a single giant function instead of composedsequence(...)calls, making it harder to test or reorder individual concerns (auth, logging) independently?
🐛 Spot the bug
import { redirect } from '@sveltejs/kit';
export const load = async ({ fetch }) => {try { const res = await fetch('/api/report-status'); if (!res.ok) throw new Error('status check failed'); redirect(303, '/reports/done');} catch (e) { console.error('could not check report status', e); return { pending: true };}};Show the answer
redirect(303, ...) throws a special SvelteKit object, and this try/catch — written to handle a fetch failure — catches that too, indistinguishably from a real error. The request silently returns 200 with { pending: true } instead of the intended 303 redirect, and nothing here signals a problem: no type error, no thrown exception reaching the user, just wrong behavior (proven with an equivalent example earlier in this lesson via real curl output — 200 instead of 303). The fix: import { isRedirect } from '@sveltejs/kit', and at the top of the catch block, if (isRedirect(e)) throw e; before doing anything else — rethrow SvelteKit’s own control-flow signals instead of treating them as the failure this catch block was written for.
Mental Model
Section titled “Mental Model”redirect() and error() are not return values — they’re throws that SvelteKit’s own request-handling loop is specifically listening for, several stack frames up from your load/action. Any try/catch between where they’re thrown and where SvelteKit catches them is an interception point, whether you intended it to be one or not. Hooks form a pipeline around every request (handle, composed via sequence) with side pipelines for specific moments (handleFetch for outgoing server fetches, handleError for genuinely unexpected failures, reroute before matching even happens). Deployment configuration (adapter, env, CSP) all resolves at one of exactly two moments — build time (static env, the adapter’s own packaging) or runtime (dynamic env, the CSP nonce, ORIGIN) — and conflating the two is the single most common deployment bug in this whole area.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
sequence(a, b, c) for handle | Each concern (auth, logging) testable/reorderable independently | One more import, one more level of indirection to read |
$env/static/private | Fails the build if the variable is missing — no silent undefined at runtime | Value is frozen at build time (proven above) — wrong for anything that must vary per deployment |
$env/dynamic/private | Reads live at runtime — correct for per-deployment secrets | No build-time guarantee it’s set; a typo’d key just returns undefined |
adapter-node | Full control, runs anywhere Node runs | You own the process (env vars like ORIGIN, proven above, and PORT) instead of a platform managing it |
CSP with mode: 'auto' | Real protection with zero manual nonce plumbing | Any inline <script>/<style> you hand-author in app.html needs the %sveltekit.nonce% placeholder or it’s blocked |
Common Misconceptions
Section titled “Common Misconceptions”- “A
try/catcharound aloadfunction only affects real errors.” Proven above: it also silently swallowsredirect()/error(), turning an intended303into a200with no warning anywhere. - “
$env/static/privateand$env/dynamic/privateare interchangeable — just two ways to read the same env var.” Proven above by grepping the actual build output: static is a literal baked in at build time; dynamic readsprocess.envlive, and can differ from what was baked in. - “
handlehas to be one function.”sequence(...)(proven above, its header trace shows every composed hook ran in order) composes several, each independently testable. - “CSP nonces are the same on every request, so they can be hardcoded for testing.” Proven above (implicitly, by the header appearing per-request): a real nonce is generated fresh per request specifically so it can’t be replayed or guessed.