Worked review 3: a SvelteKit settings app with login and form actions
The feature
Section titled “The feature”The prompt was: “Build a small settings app: a login form that sets the current user, and a settings page that shows who is logged in and the app’s public name.” This is the shortest feature in this module by file count, and the most dangerous — every one of its 5 real bugs lives at the SvelteKit server boundary, the last step in this module’s reading order: a try/catch that swallows a real redirect, a validation failure that lies about its own HTTP status, a private secret imported where the browser can reach it, deprecated store syntax, and — worst of all — a “current user” that is not actually scoped to a request at all. None of these five are compile errors; all five are proven here against a real, built adapter-node server with curl.
The generated code
Section titled “The generated code”// src/lib/server/session.svelte.ts — as generated// shared module-level state — the whole running SERVER process's "current user"export const session = $state({ user: null as string | null });// src/routes/wr3/login/+page.server.ts — as generatedimport { redirect } from '@sveltejs/kit';import { session } from '$lib/server/session.svelte';import type { Actions } from './$types';
export const actions: Actions = { default: async ({ request }) => { const data = await request.formData(); const username = data.get('username')?.toString() ?? ''; if (!username) { return { error: 'Username is required' }; // BUG: plain object, not fail() } try { session.user = username; redirect(303, '/wr3/settings'); // throws — swallowed below } catch (e) { console.error('login failed', e); return { error: 'Something went wrong' }; } }};<!-- src/routes/wr3/login/+page.svelte — as generated --><script lang="ts"> let { form }: { form?: { error?: string } } = $props();</script><h1>Log in</h1><form method="POST"> <input name="username" /> <button>Log in</button></form>{#if form?.error}<p data-testid="error">{form.error}</p>{/if}// src/routes/wr3/settings/+page.server.ts — as generatedimport { session } from '$lib/server/session.svelte';import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { return { user: session.user };};// src/routes/wr3/settings/+page.ts — as generatedimport { API_SECRET } from '$env/static/private'; // BUG: private env in a universal loadimport type { PageLoad } from './$types';
export const load: PageLoad = async () => { return { hasSecret: Boolean(API_SECRET) };};<!-- src/routes/wr3/settings/+page.svelte — as generated --><script lang="ts"> import { page } from '$app/stores'; // BUG: deprecated since Kit 2.12 let { data }: { data: { user: string | null } } = $props();</script><h1>Settings</h1><p data-testid="user">Logged in as: {data.user ?? 'nobody'}</p><p data-testid="path">Current path: {$page.url.pathname}</p>The review walk
Section titled “The review walk”The SvelteKit server boundary: throw redirect() swallowed by a broad try/catch
Section titled “The SvelteKit server boundary: throw redirect() swallowed by a broad try/catch”The login action wraps session.user = username; redirect(303, ...) in a try/catch meant to handle a genuinely failing write.
🐛 Spot the bug
try {session.user = username;redirect(303, '/wr3/settings'); // throws} catch (e) {console.error('login failed', e);return { error: 'Something went wrong' };}Show the answer
redirect() and error() work by throwing a special, recognized object — exactly the classic bug already proven with real curl output in Server & API. A generic catch cannot tell that thrown value apart from a real failure, and swallows it. Proven against this exact route, built with adapter-node and run as a real Node process:
$ curl -s -i -X POST http://localhost:5302/wr3/login \ -d "username=alice" -H "Content-Type: application/x-www-form-urlencoded" \ -H "Accept: text/html" -H "Origin: http://localhost:5302"
HTTP/1.1 200 OKcontent-type: text/html...<h1>Log in</h1> <form method="POST">...</form><p data-testid="error">Something went wrong</p>The server’s own log confirms exactly what was caught:
login failed Redirect { status: 303, location: '/wr3/settings' }A real 303 was thrown and is sitting right there in the log — but the response the browser gets is a 200 with an error message, because the catch treated it as one. The fix is isRedirect(e)/isHttpError(e) checks before treating a catch as a real failure — or, simpler here, moving redirect() entirely outside the try block, since nothing about the redirect itself can fail.
The SvelteKit server boundary: a form action returning a plain object instead of fail()
Section titled “The SvelteKit server boundary: a form action returning a plain object instead of fail()”The same action returns { error: 'Username is required' } on validation failure.
🐛 Spot the bug
if (!username) {return { error: 'Username is required' };}Show the answer
This lands in the form prop exactly like fail() data would — {#if form?.error} renders fine either way — so it is easy to miss in a quick read. Proven against the built app:
$ curl -s -i -X POST http://localhost:5302/wr3/login \ -d "username=" -H "Content-Type: application/x-www-form-urlencoded" \ -H "Accept: application/json" -H "Origin: http://localhost:5302" -H "x-sveltekit-action: true"
HTTP/1.1 200 OKcontent-type: application/json
{"type":"success","status":200,"data":"[{\"error\":1},\"Username is required\"]"}"type":"success" and status 200 — for a request that failed validation. This exact gap (the shape is right, the HTTP status is wrong) is already proven end to end in Forms & Actions. The fix is return fail(400, { error: 'Username is required' }).
The SvelteKit server boundary: a private env var imported into a universal load
Section titled “The SvelteKit server boundary: a private env var imported into a universal load”+page.ts (a universal load — it can run in the browser, not just the server) imports $env/static/private.
🐛 Spot the bug
import { API_SECRET } from '$env/static/private';Show the answer
+page.js/+page.ts loads are not server-only — they run again on the client during client-side navigation. Vite’s own SvelteKit guard catches this at build time, before it ever ships:
$ npx vite build
Error: Cannot import $env/static/private into code that runs in the browser, as this could leak sensitive information.
src/routes/wr3/settings/+page.ts imports $env/static/privateThe build fails outright — this never reaches production. The fix is either moving the code into +page.server.ts (server-only, safe for any private import) or, if the value genuinely needs to reach the browser, using $env/static/public (any PUBLIC_-prefixed variable) instead — shown in the corrected code.
The SvelteKit server boundary: $page from the deprecated $app/stores
Section titled “The SvelteKit server boundary: $page from the deprecated $app/stores”The settings page imports page from $app/stores.
🐛 Spot the bug
import { page } from '$app/stores';...<p>Current path: {$page.url.pathname}</p>Show the answer
This compiles and runs today — $app/stores has not been removed. But Loading Data already proves, straight from the installed SvelteKit’s own source, that every export is marked deprecated:
9: * @deprecated Use `page` from `$app/state` instead (requires Svelte 5, see docs for more info)The fix is importing page from $app/state instead and dropping the $ prefix — page there is already a reactive object, not a store.
State ownership: a “current user” that lives in shared, module-level $state
Section titled “State ownership: a “current user” that lives in shared, module-level $state”session.svelte.ts holds the current user in a module-level $state object, imported by both the login action and the settings load.
🐛 Spot the bug
export const session = $state({ user: null as string | null });Show the answer
Every importer shares the exact same object — proven directly in the $state Rune lesson: there is one instance for the whole running process, not one per request. On the server, that means one user’s login can leak into a completely unrelated request. Proven against the real built app: log in as "carol" from one curl call, then make a second, completely fresh curl call with no cookies at all — simulating an unrelated visitor — to the settings page:
$ curl -s -o /dev/null -X POST http://localhost:5302/wr3/login \ -d "username=carol" -H "Content-Type: application/x-www-form-urlencoded" \ -H "Origin: http://localhost:5302"
$ curl -s http://localhost:5302/wr3/settings | grep -o 'data-testid="user">[^<]*'data-testid="user">Logged in as: carolA request that never logged in, and carries no cookie identifying it as carol, is shown carol’s session anyway — because session is one shared object for the whole server process. The fix is never storing per-user data at module scope on the server: read it fresh, per request, from something request-scoped — a cookie via event.locals, populated in hooks.server.ts — shown in the corrected code.
The corrected code
Section titled “The corrected code”declare global { namespace App { interface Locals { username: string | null; } }}
export {};import type { Handle } from '@sveltejs/kit';
// FIX (module-state SSR leak): the current user is read fresh from a// per-request cookie into event.locals — never a shared module-level// $state — so one request can never see another request's user.export const handle: Handle = async ({ event, resolve }) => { event.locals.username = event.cookies.get('user') ?? null; return resolve(event);};import { fail, isRedirect, redirect } from '@sveltejs/kit';import type { Actions } from './$types';
export const actions: Actions = { default: async ({ request, cookies }) => { const data = await request.formData(); const username = data.get('username')?.toString() ?? ''; if (!username) { // FIX (fail() vs plain object): a real 4xx status, not a 200. return fail(400, { error: 'Username is required' }); } try { cookies.set('user', username, { path: '/' }); } catch (e) { // FIX (redirect swallowed by try/catch): rethrow anything SvelteKit // itself recognizes as a redirect/HTTP error before treating the // catch as a real failure. if (isRedirect(e)) throw e; console.error('login failed', e); return fail(500, { error: 'Something went wrong' }); } redirect(303, '/wr3/settings'); // thrown OUTSIDE any try/catch }};<script lang="ts"> let { form }: { form?: { error?: string } } = $props();</script><h1>Log in</h1><form method="POST"> <input name="username" /> <button>Log in</button></form>{#if form?.error}<p data-testid="error">{form.error}</p>{/if}import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => { // FIX (module-state SSR leak): locals.username is set fresh per request by // hooks.server.ts, never shared across requests. return { user: locals.username };};// src/routes/wr3/settings/+page.ts — illustrative (the proof below is a real build/run, not this harness's shared probe, which has no .env)import { PUBLIC_APP_NAME } from '$env/static/public'; // FIX: only PUBLIC_ env vars in a universal loadimport type { PageLoad } from './$types';
export const load: PageLoad = async ({ data }) => { return { ...data, appName: PUBLIC_APP_NAME };};<script lang="ts"> import { page } from '$app/state'; // FIX: $app/state, not the deprecated $app/stores let { data }: { data: { user: string | null; appName: string } } = $props();</script><h1>Settings — {data.appName}</h1><p data-testid="user">Logged in as: {data.user ?? 'nobody'}</p><p data-testid="path">Current path: {page.url.pathname}</p>import { describe, it, expect, vi } from 'vitest';import { handle } from './hooks.server';
describe('hooks.server handle (fixed): locals.username comes from a per-request cookie', () => { it('reads the cookie fresh for each event — never a shared module value', async () => { const resolve = vi.fn(async () => new Response('ok'));
const eventWithCookie = { cookies: { get: () => 'alice' }, locals: {} } as any; await handle({ event: eventWithCookie, resolve } as any); expect(eventWithCookie.locals.username).toBe('alice');
const eventWithoutCookie = { cookies: { get: () => undefined }, locals: {} } as any; await handle({ event: eventWithoutCookie, resolve } as any); expect(eventWithoutCookie.locals.username).toBeNull(); });});✓ hooks.server handle (fixed): locals.username comes from a per-request cookie > reads the cookie fresh for each event — never a shared module value
Test Files 1 passed (1) Tests 1 passed (1)Proven end to end against the same real, built adapter-node server — a genuine 303, the right HTTP status on failure, and per-request isolation:
$ curl -s -i -X POST http://localhost:5303/wr3/login \ -d "username=" -H "Content-Type: application/x-www-form-urlencoded" \ -H "Accept: application/json" -H "Origin: http://localhost:5303" -H "x-sveltekit-action: true"
HTTP/1.1 200 OK{"type":"failure","status":400,"data":"[{\"error\":1},\"Username is required\"]"}
$ curl -s -i -X POST http://localhost:5303/wr3/login \ -d "username=dave" -H "Content-Type: application/x-www-form-urlencoded" \ -H "Origin: http://localhost:5303" -c cookies.txt
HTTP/1.1 200 OKset-cookie: user=dave; Path=/; HttpOnly; SameSite=Lax{"type":"redirect","status":303,"location":"/wr3/settings"}
$ curl -s -b cookies.txt http://localhost:5303/wr3/settings | grep -o 'data-testid="user">[^<]*'data-testid="user">Logged in as: dave
$ curl -s http://localhost:5303/wr3/settings | grep -o 'data-testid="user">[^<]*'data-testid="user">Logged in as: nobodyThe last line is the whole fix in miniature: a completely fresh request, with no cookie at all, sees nobody — never dave — because the user now lives in a per-request cookie read into event.locals, not a shared module object. npx vite build also now succeeds (the private-env import is gone), where the buggy version failed outright.
What changed and why
Section titled “What changed and why”| # | Bug | Category | Fix |
|---|---|---|---|
| 1 | throw redirect() caught by a broad try/catch | SvelteKit server boundary | isRedirect(e) check; rethrow before treating the catch as real |
| 2 | Validation failure returns a plain object, not fail() | SvelteKit server boundary | fail(400, { error }) — same shape, correct HTTP status |
| 3 | $env/static/private imported in a universal +page.ts | SvelteKit server boundary | Move to +page.server.ts, or use $env/static/public |
| 4 | import { page } from '$app/stores' | SvelteKit server boundary | import { page } from '$app/state', no $ prefix |
| 5 | Module-level $state holding the current user | State ownership | event.locals.username, set per-request from a cookie in hooks.server.ts |
Mental Model
Section titled “Mental Model”Every bug in this review is invisible to a component-level read: redirect()/error() look like ordinary function calls until you know they throw; a plain object and fail()’s return value look identical in the form prop; a universal load looks identical to a server load until the client tries to run it; $app/stores looks identical to $app/state at a glance; and a module-level $state object looks exactly like the “shared state” pattern this course’s own Advanced Runes lesson recommends for client-only app state. The server boundary is where “looks right” and “is right” diverge the most sharply in all of Svelte — which is why every proof in this review came from an actual running process, not a read-through.
Common Misconceptions
Section titled “Common Misconceptions”- “
redirect()/error()are return values, like everywhere else in JavaScript.” Proven false, with the server’s own log showing the realRedirectobject being caught: they are throws, and anytry/catcharound code that calls them needs an explicitisRedirect/isHttpErrorcheck. - “Returning
{ error: '...' }from an action is the same asfail(400, { error: '...' }).” Proven false: both land in theformprop, but onlyfail()sets the correct non-200HTTP status — visible only by inspecting the response, not the rendered page. - “A universal
load(+page.js) is just a server load that also happens to run on the client.” Proven false with a real build failure: it is held to the browser’s rules everywhere, including for its own imports. - “A module-level
$stateobject is a fine, lightweight way to share the current user.” Proven false on the server specifically: one process, one object, every request — a genuine cross-user leak, not a theoretical risk.
flowchart TD A["5. The SvelteKit server boundary"] --> A1["redirect() swallowed by try/catch"] A --> A2["plain object instead of fail()"] A --> A3["$env/static/private in a universal load"] A --> A4["$app/stores instead of $app/state"] B["2. State ownership"] --> B1["module-level $state shared across every request"]