Forms & Actions
Forms POST to actions
Section titled “Forms POST to actions”SvelteKit handles form submissions with actions — server functions in +page.server.js that receive the submitted data. A plain HTML <form method="POST"> posts to them, so the feature works before any JavaScript loads.
import { fail, redirect } from '@sveltejs/kit';
/** @param {FormDataEntryValue} email @param {FormDataEntryValue} password @returns {Promise<{ token: string } | null>} */async function authenticate(email, password) { return null; // stand-in for a real lookup against your user store}
export const actions = { default: async ({ request, cookies }) => { const data = await request.formData(); const email = data.get('email'); const password = data.get('password');
if (!email || !password) { // Return a 400 with the values so the form can re-render the error. return fail(400, { email, missing: true }); }
const user = await authenticate(email, password); if (!user) return fail(401, { email, incorrect: true });
cookies.set('session', user.token, { path: '/' }); redirect(303, '/dashboard'); // throw a redirect on success },};<script> let { form } = $props(); // the action's returned value (e.g. fail data)</script>
<form method="POST"> <input name="email" value={form?.email ?? ''} /> <input name="password" type="password" /> {#if form?.incorrect}<p>Wrong email or password</p>{/if} <button>Log in</button></form>The action’s return value (including fail(...) data) arrives as the form prop. On success you redirect; on failure you fail(status, data) so the page re-renders with the error and the user’s input preserved.
Named actions
Section titled “Named actions”One page can have several actions — a login form and a register form, say. Name them and target one with action="?/name":
export const actions = { login: async ({ request }) => { /* … */ }, register: async ({ request }) => { /* … */ },};<form method="POST" action="?/login">…</form><form method="POST" action="?/register">…</form>Progressive enhancement, proven without any JavaScript
Section titled “Progressive enhancement, proven without any JavaScript”The form above already works with no JavaScript at all — the browser posts, the server responds, the browser follows the redirect. Proving it with curl (no browser, no JS, an Accept header shaped like a real browser navigation’s) against a real built app:
$ curl -s -D - -o /dev/null -X POST http://localhost:5196/login \ -H "Origin: http://localhost:5196" \ -H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \ -d "[email protected]"HTTP/1.1 303 See Otherlocation: /dashboard$ curl -s -D - -o /dev/null -X POST http://localhost:5196/login \ -H "Origin: http://localhost:5196" \ -H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \ -d "email="HTTP/1.1 400 Bad Requestcontent-type: text/htmlNo JavaScript executed anywhere in that request — a real 303 redirect on success, and a real 400 with a full re-rendered HTML page (carrying the fail() data as the form prop) on failure. This is the whole point of building on top of <form method="POST">: the mechanism is standard HTTP, not a client-side framework feature.
use:enhance with a custom submit callback
Section titled “use:enhance with a custom submit callback”Plain use:enhance (no arguments) upgrades the form to a smooth AJAX submission with sensible defaults. Passing it a function lets you customize what happens around the submit — the function runs before the request, and can return a callback that runs after the response, receiving result and an update() you control:
<script lang="ts"> import { enhance, applyAction } from '$app/forms'; let { form } = $props(); let submitting = $state(false);
function customEnhance() { submitting = true; return async ({ result, update }: { result: any; update: () => Promise<void> }) => { submitting = false; if (result.type === 'redirect') { await applyAction(result); // apply the redirect ourselves } else { await update(); // default behavior: update form prop, re-run load } }; }</script>
<form method="POST" use:enhance={customEnhance}> <input name="email" value={form?.email ?? ''} /> <button>Log in</button></form>{#if submitting}<p>submitting…</p>{/if}update() is the same thing plain use:enhance does automatically: it updates the form prop and re-runs load (equivalent to invalidateAll()). applyAction(result) applies a returned redirect/error/success result the same way SvelteKit’s default handler would — useful when you’ve branched on result.type yourself for some cases and want the default behavior for others. svelte-check confirms this compiles cleanly against the installed SvelteKit types (verified against 2.70.3).
flowchart TB nojs["No JS: browser POSTs the form"] --> server["action runs on the server"] js["With use:enhance: AJAX submit, no reload"] --> server server --> result["redirect on success, or fail data → form prop"]
This is SvelteKit’s signature move: build with plain forms that work everywhere, then enhance for a better experience — instead of a JS-only form that breaks without it.
Remote functions (experimental): a different way to talk to the server
Section titled “Remote functions (experimental): a different way to talk to the server”SvelteKit is developing an alternative to load/actions/+server.js for server communication: remote functions, imported from $app/server inside a file named *.remote.ts (or .js). Four kinds exist: query (read), form (a form-backed mutation), command (an imperative mutation), and prerender (a query resolved at build time).
// vite.config.js — enabling the flag (flattened kit options passed to the plugin)import { sveltekit } from '@sveltejs/kit/vite';export default { plugins: [sveltekit({ experimental: { remoteFunctions: true } })],};// src/lib/posts.remote.js — experimental: requires kit.experimental.remoteFunctionsimport { query, command } from '$app/server';
let posts = ['first post', 'second post'];
export const getPosts = query(() => posts);
export const addPost = command('unchecked', (title) => { posts = [...posts, title]; return { ok: true };});Called from a component, getPosts() behaves like a reactive query (it re-fetches when invalidated) and addPost(title) behaves like a callable mutation — no route file, no fetch, no manually wiring a +server.js endpoint. This compiled and type-checked cleanly (svelte-check, 0 errors) against Kit 2.70.3 with the flag enabled — but given the explicit “not yet stable” language above, treat it as a preview of where data fetching is heading, not something to build production code on today.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated form/action code, check for:
- Does an action return a plain object on validation failure (
return { error: 'bad' }) instead offail(status, data)? A plain object looks like it works (the shape lands in theformprop either way) but the response status stays200, which breaks anything that branches on HTTP status — and skips SvelteKit’s ownActionFailuretyping. - Does it throw
redirect()/error()inside atry/catchmeant for something else, silently swallowing the control-flow throw? (Full proof of this exact bug, with acurl-verified fix, is in the hooks lesson.) - Does a
use:enhancecallback forget to callupdate()orapplyAction()in every branch, leaving theformprop stale after some responses? - Does it treat remote functions as a stable, always-available API without checking
kit.experimental.remoteFunctionsis even enabled, or without flagging the code as experimental to the reader? - Is there a
matcher-free assumption that a form submits with JavaScript always on — no plain-<form>fallback at all?
🐛 Spot the bug
import { redirect } from '@sveltejs/kit';
export const actions = {default: async ({ request }) => { const data = await request.formData(); const name = data.get('name');
if (!name) { return { error: 'name is required' }; // looks like a validation error... }
redirect(303, '/settings');},};Show the answer
This returns a plain object ({ error: '...' }) on failure instead of fail(400, { error: '...' }). It still lands in the form prop — {#if form?.error} in the page would render fine — so this bug is easy to miss in a quick read. But the HTTP response status stays 200 OK instead of the 400 a validation failure should carry, which breaks any code that branches on status (analytics, a retry policy, use:enhance’s own result.type checks, which expect "failure" only from an actual fail() call). The fix is return fail(400, { error: 'name is required' }) — same shape in the form prop, correct HTTP semantics underneath.
Mental Model
Section titled “Mental Model”An action is a plain HTTP form handler first, and a JavaScript-enhanced one second. Every decision in this lesson follows from that ordering: fail() returns data the page re-renders with (not a thrown error, which would show the framework’s error page instead of the form); redirect() is a real HTTP redirect the browser can follow with zero JavaScript; use:enhance is strictly an upgrade layered on top of behavior that already works. Remote functions are a different mental model entirely — closer to calling a function than submitting a form — and being explicitly experimental, they’re worth knowing about but not worth building on without checking their status again first.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Plain <form method="POST">, no use:enhance | Works with zero JavaScript, proven above with real curl | Full page reload on every submit |
use:enhance (no args) | AJAX submit, form/load updated automatically | Less control over exactly what happens around the request |
use:enhance={fn} (custom callback) | Full control (update(), applyAction(), custom branching) | More code, more ways to forget a branch (proven in SpotTheBug above) |
| Remote functions | No route boilerplate for simple reads/writes | Experimental — “may be changed or removed at any time,” per Kit’s own types |
Common Misconceptions
Section titled “Common Misconceptions”- “Returning
{ error: '...' }from an action is the same asfail(400, { error: '...' }).” Proven above: both land in theformprop, but onlyfail()sets the correct non-200 HTTP status. - “
use:enhanceis required for the form to work.” Proven above with realcurl: a plain<form method="POST">gets a real303/400with zero JavaScript.use:enhanceonly improves the experience. - “Remote functions are a stable, ready-to-use replacement for
load/actions.” Kit’s own types call them explicitly experimental and subject to change — worth knowing, not worth depending on yet. - “A
403 Forbiddenon a form POST means the code is wrong.” It can just as easily mean the deployed origin isn’t configured correctly foradapter-node(proven above) — a deployment issue, not a code bug.