Skip to content

Forms & 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.

src/routes/login/+page.server.js
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
},
};
src/routes/login/+page.svelte
<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.

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" \
HTTP/1.1 303 See Other
location: /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 Request
content-type: text/html

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

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:

src/routes/login/+page.svelte
<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"]
Progressive enhancement: works without JS, better with it

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.remoteFunctions
import { 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.

When reviewing generated form/action code, check for:

  • Does an action return a plain object on validation failure (return { error: 'bad' }) instead of fail(status, data)? A plain object looks like it works (the shape lands in the form prop either way) but the response status stays 200, which breaks anything that branches on HTTP status — and skips SvelteKit’s own ActionFailure typing.
  • Does it throw redirect()/error() inside a try/catch meant for something else, silently swallowing the control-flow throw? (Full proof of this exact bug, with a curl-verified fix, is in the hooks lesson.)
  • Does a use:enhance callback forget to call update() or applyAction() in every branch, leaving the form prop stale after some responses?
  • Does it treat remote functions as a stable, always-available API without checking kit.experimental.remoteFunctions is 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

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

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.

What you getBenefitCost
Plain <form method="POST">, no use:enhanceWorks with zero JavaScript, proven above with real curlFull page reload on every submit
use:enhance (no args)AJAX submit, form/load updated automaticallyLess 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 functionsNo route boilerplate for simple reads/writesExperimental — “may be changed or removed at any time,” per Kit’s own types
  • “Returning { error: '...' } from an action is the same as fail(400, { error: '...' }).” Proven above: both land in the form prop, but only fail() sets the correct non-200 HTTP status.
  • “use:enhance is required for the form to work.” Proven above with real curl: a plain <form method="POST"> gets a real 303/400 with zero JavaScript. use:enhance only 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 Forbidden on a form POST means the code is wrong.” It can just as easily mean the deployed origin isn’t configured correctly for adapter-node (proven above) — a deployment issue, not a code bug.
Where do SvelteKit form actions live?
What is wrong with returning { error: 'required' } here?
export const actions = {
  default: async ({ request }) => {
    const name = (await request.formData()).get('name');
    if (!name) return { error: 'required' };   // ?
    redirect(303, '/ok');
  },
};
How does an action report a validation error while keeping the user's input?
This curl command never runs any JavaScript. What does the real 303 response prove?
$ curl -s -D - -o /dev/null -X POST http://localhost:5196/login \
  -H "Accept: text/html,..." -d "[email protected]"
HTTP/1.1 303 See Other
location: /dashboard
What does use:enhance={customFn} let you do that plain use:enhance does not?
What is true about SvelteKit's remote functions (query/form/command/prerender from $app/server)?