Skip to content

Worked review 3: a SvelteKit settings app with login and form actions

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.

// 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 generated
import { 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 generated
import { 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 generated
import { API_SECRET } from '$env/static/private'; // BUG: private env in a universal load
import 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 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 OK
content-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 OK
content-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

src/routes/wr3/settings/+page.ts
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/private

The 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

src/lib/server/session.svelte.ts
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: carol

A 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.

src/app.d.ts
declare global {
namespace App {
interface Locals {
username: string | null;
}
}
}
export {};
src/hooks.server.ts
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);
};
src/routes/wr3/login/+page.server.ts
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
}
};
src/routes/wr3/login/+page.svelte
<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
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 load
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ data }) => {
return { ...data, appName: PUBLIC_APP_NAME };
};
src/routes/wr3/settings/+page.svelte
<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>
src/hooks.server.test.ts
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 OK
set-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: nobody

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

#BugCategoryFix
1throw redirect() caught by a broad try/catchSvelteKit server boundaryisRedirect(e) check; rethrow before treating the catch as real
2Validation failure returns a plain object, not fail()SvelteKit server boundaryfail(400, { error }) — same shape, correct HTTP status
3$env/static/private imported in a universal +page.tsSvelteKit server boundaryMove to +page.server.ts, or use $env/static/public
4import { page } from '$app/stores'SvelteKit server boundaryimport { page } from '$app/state', no $ prefix
5Module-level $state holding the current userState ownershipevent.locals.username, set per-request from a cookie in hooks.server.ts

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.

  • “redirect()/error() are return values, like everywhere else in JavaScript.” Proven false, with the server’s own log showing the real Redirect object being caught: they are throws, and any try/catch around code that calls them needs an explicit isRedirect/isHttpError check.
  • “Returning { error: '...' } from an action is the same as fail(400, { error: '...' }).” Proven false: both land in the form prop, but only fail() sets the correct non-200 HTTP 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 $state object 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"]
Where each bug sits in the reading order
What does curl see when this action succeeds, against the real built app?
try {
  session.user = username;
  redirect(303, '/wr3/settings');
} catch (e) {
  return { error: 'Something went wrong' };
}
What does the actual HTTP response look like for this validation failure?
if (!username) {
  return { error: 'Username is required' };
}
+page.ts (a universal load) imports API_SECRET from $env/static/private. What happens when the project is built?
What is the real risk of export const session = $state({ user: null }) in a .svelte.ts module imported by server code?
What is the fix for the module-state leak, and why does it work per-request?