Skip to content

Server & API

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.

src/routes/api/posts/+server.js
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):

HookVerified location (installed Kit 2.70.3)Purpose
handletypes/index.d.ts:924 doc comment + used everywhereRuns on every request before the route; set locals, short-circuit, or modify the response
sequencetypes/index.d.ts:3136, export function sequence(...handlers: Handle[]): Handle;Compose multiple handle functions in order, imported from @sveltejs/kit/hooks
handleFetchtypes/index.d.ts:958Rewrites/replaces the result of event.fetch calls made on the server
handleErrortypes/index.d.ts:924, 945Runs when an unexpected (non-error()/redirect()) exception escapes handle, load, or an action
reroutetypes/index.d.ts:979Rewrites the URL used to determine which route matches, before matching happens
transporttypes/index.d.ts:985Serializes custom class instances across the server/client boundary (encode/decode pair)
src/hooks.server.js
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-sequence
x-hooks-sequence: addLocalsUser,logRequest

Whatever 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:

src/app.d.ts
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 request passes through handle, then the route

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 redirect
import { 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-bug
HTTP 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 failures
import { 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 Other
location: /dashboard

The 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>

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’s prerender setting also accepts an entries array (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):

AdapterVersionTarget
@sveltejs/adapter-auto5.5.7Detects the platform (Vercel, Netlify, Cloudflare, …) automatically
@sveltejs/adapter-node7.2.9A standalone Node server
@sveltejs/adapter-cloudflare6.3.4A Cloudflare Worker/Pages
@sveltejs/adapter-static3.0.10A 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/*.js
var 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.

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:

vite.config.js
sveltekit({
csp: {
mode: 'auto',
directives: { 'script-src': ['self'] },
},
});
$ curl -sI http://localhost:5196/ | grep -i content-security-policy
content-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).

When reviewing generated server/hooks/deployment code, check for:

  • Does a try/catch wrap a call to redirect()/error() without checking isRedirect/isHttpError before treating the catch as a real failure? Proven above with real curl output: a 200 where a 303 was intended, with no compile-time signal that anything is wrong.
  • Does event.locals get used with no App.Locals interface declared in src/app.d.ts — silently typed any everywhere it’s read?
  • Does a secret read via $env/static/private get 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/private reads 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 handle get written as a single giant function instead of composed sequence(...) calls, making it harder to test or reorder individual concerns (auth, logging) independently?

🐛 Spot the bug

src/routes/api/report/+page.server.js
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.

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.

What you getBenefitCost
sequence(a, b, c) for handleEach concern (auth, logging) testable/reorderable independentlyOne more import, one more level of indirection to read
$env/static/privateFails the build if the variable is missing — no silent undefined at runtimeValue is frozen at build time (proven above) — wrong for anything that must vary per deployment
$env/dynamic/privateReads live at runtime — correct for per-deployment secretsNo build-time guarantee it’s set; a typo’d key just returns undefined
adapter-nodeFull control, runs anywhere Node runsYou 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 plumbingAny inline <script>/<style> you hand-author in app.html needs the %sveltekit.nonce% placeholder or it’s blocked
  • “A try/catch around a load function only affects real errors.” Proven above: it also silently swallows redirect()/error(), turning an intended 303 into a 200 with no warning anywhere.
  • “$env/static/private and $env/dynamic/private are 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 reads process.env live, and can differ from what was baked in.
  • “handle has 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.
What is a +server.js file?
Which of these are real, currently-exported SvelteKit hooks, verified against the installed Kit?
What does curling this route actually return?
export const load = async () => {
  try {
    redirect(303, '/dashboard');
  } catch (e) {
    return { fallback: true };
  }
};
What declares the type of event.locals across the whole app?
The app is then started with a DIFFERENT runtime value of the same env var. What happens to this value read via $env/static/private?
$ grep SECRET_API_KEY build/server/chunks/.../env-test/*.js
var SECRET_API_KEY = "build-time-secret-abc123";
What generates the Content-Security-Policy header seen in curl -I output?