Form Actions
SvelteKit form actions for progressive enhancement with server-side form handling
You are an expert in SvelteKit form actions, helping developers build forms that work without JavaScript and progressively enhance with client-side interactivity. ## Key Points - **Forgetting `method="POST"` on Forms** — leaving the default `method="GET"`, which sends form data as URL query parameters instead of triggering the action handler, causing silent failures. - **Always use `fail()` for validation errors** rather than throwing or returning plain objects. `fail` sets the correct HTTP status and makes the data available through `form`. - **Redirect after mutation** with `redirect(303, '/path')` to prevent form resubmission on back/refresh (Post-Redirect-Get pattern). - **Preserve user input on error** by returning submitted values (like `email`) from `fail()` and repopulating form fields. - **Use `use:enhance`** on all forms for a smoother experience. It gracefully degrades — the form still works without JS. - **Keep actions in `+page.server.js`** (not `+server.js`). Actions are tightly coupled to pages and benefit from automatic `form` prop binding. - **Forgetting `method="POST"`.** Forms default to GET, which does not trigger actions. - **Returning non-serializable data from actions.** Action return values must be serializable (no functions, classes, or circular references). - **Not awaiting `update()` in custom enhance.** Skipping `await update()` means load functions are not re-run and the page does not reflect the change. - **Using `action="?/name"` with `default`.** A page with only a `default` action should use `action=""` or omit the attribute, not `action="?/default"`. - **CSRF token confusion.** SvelteKit handles CSRF protection automatically by checking the `Origin` header. Do not implement a custom CSRF solution unless you have disabled the built-in one. ## Quick Example ```svelte <form method="POST" action="?/upload" enctype="multipart/form-data" use:enhance> <input type="file" name="avatar" accept="image/*" /> <button>Upload</button> </form> ``` ```svelte <form method="POST" action="/todos?/create" use:enhance> <input name="text" /> <button>Quick Add Todo</button> </form> ```
skilldb get svelte-skills/Form ActionsFull skill: 260 linesForm Actions — SvelteKit
You are an expert in SvelteKit form actions, helping developers build forms that work without JavaScript and progressively enhance with client-side interactivity.
Core Philosophy
SvelteKit form actions are built on a simple but powerful idea: forms should work without JavaScript. The <form method="POST"> element is the web platform's native mechanism for submitting data to a server, and SvelteKit embraces this by making server-side form handling the default behavior. Progressive enhancement via use:enhance layers on a better experience for JavaScript-enabled clients, but the baseline functionality never depends on client-side code. This is not just about accessibility — it makes forms resilient, testable, and debuggable.
The Post-Redirect-Get pattern is central to how actions work. After a successful mutation, you redirect with redirect(303, '/path') so the browser makes a GET request to the target. This prevents form resubmission on back/refresh and keeps browser history clean. When validation fails, fail() returns data without redirecting, preserving the user's input and showing error messages. This two-path flow — redirect on success, return errors on failure — is the pattern every form action should follow.
Actions are deliberately co-located with their pages in +page.server.js. This tight coupling is a feature: the form's markup, its validation logic, and its server handler all live together, making it easy to understand the full lifecycle of a form submission. Moving action logic into separate API endpoints (+server.js) breaks this co-location and loses the automatic form prop binding that makes error display trivial.
Anti-Patterns
-
Forgetting
method="POST"on Forms — leaving the defaultmethod="GET", which sends form data as URL query parameters instead of triggering the action handler, causing silent failures. -
Returning Non-Serializable Data from Actions — returning class instances, functions, or objects with circular references from an action. Action return values must be serializable since they are transferred between server and client.
-
Skipping
await update()in Custom Enhance — providing a custom callback touse:enhancebut not callingawait update(), which means load functions are not re-run and the page does not reflect the mutation. -
Implementing Custom CSRF Protection — adding manual CSRF tokens when SvelteKit already handles CSRF protection automatically by verifying the
Originheader. Custom solutions conflict with the built-in mechanism. -
Not Preserving User Input on Validation Failure — returning
fail(400, { missing: true })without including the submitted values (likeemail), forcing users to re-enter everything after a validation error.
Overview
SvelteKit form actions allow you to define server-side handlers for <form> submissions directly alongside your pages. Forms work natively with standard POST requests — no JavaScript required — and SvelteKit's use:enhance directive layering on client-side enhancement for a seamless user experience.
Core Concepts
Defining Actions
Actions are exported from +page.server.js and handle POST requests.
// src/routes/login/+page.server.js
import { fail, redirect } from '@sveltejs/kit';
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 fail(400, { email, missing: true });
}
const user = await authenticate(email, password);
if (!user) {
return fail(401, { email, incorrect: true });
}
cookies.set('session', user.sessionId, { path: '/' });
redirect(303, '/dashboard');
}
};
Named Actions
Multiple actions per page use named exports:
// src/routes/todos/+page.server.js
export const actions = {
create: async ({ request }) => {
const data = await request.formData();
await db.createTodo(data.get('text'));
},
delete: async ({ request }) => {
const data = await request.formData();
await db.deleteTodo(data.get('id'));
}
};
Invoke named actions via the action attribute with a query parameter:
<form method="POST" action="?/create">
<input name="text" />
<button>Add</button>
</form>
<form method="POST" action="?/delete">
<input type="hidden" name="id" value={todo.id} />
<button>Delete</button>
</form>
The fail Helper
Return validation errors without redirecting. Data returned by fail is available in the page via $page.form or the form prop.
import { fail } from '@sveltejs/kit';
export const actions = {
register: async ({ request }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
const errors = {};
if (!email?.includes('@')) errors.email = 'Invalid email';
if (password?.length < 8) errors.password = 'Must be 8+ chars';
if (Object.keys(errors).length) {
return fail(422, { errors, email });
}
await createUser(email, password);
return { success: true };
}
};
Displaying Action Results
<!-- src/routes/register/+page.svelte -->
<script>
let { form } = $props();
</script>
{#if form?.success}
<p class="success">Account created!</p>
{/if}
<form method="POST" action="?/register">
<label>
Email
<input name="email" value={form?.email ?? ''} />
{#if form?.errors?.email}
<span class="error">{form.errors.email}</span>
{/if}
</label>
<label>
Password
<input name="password" type="password" />
{#if form?.errors?.password}
<span class="error">{form.errors.password}</span>
{/if}
</label>
<button>Register</button>
</form>
Implementation Patterns
Progressive Enhancement with use:enhance
The enhance action intercepts form submission, sends it via fetch, and updates the page without a full reload.
<script>
import { enhance } from '$app/forms';
</script>
<form method="POST" action="?/create" use:enhance>
<input name="text" />
<button>Add Todo</button>
</form>
Custom Enhance Behavior
Pass a callback to use:enhance for custom handling:
<script>
import { enhance } from '$app/forms';
let submitting = $state(false);
</script>
<form
method="POST"
action="?/save"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
submitting = false;
if (result.type === 'success') {
// Custom success handling
showToast('Saved!');
}
await update(); // apply default behavior (rerun load, etc.)
};
}}
>
<button disabled={submitting}>
{submitting ? 'Saving...' : 'Save'}
</button>
</form>
File Uploads
Forms handle file uploads natively with enctype="multipart/form-data":
<form method="POST" action="?/upload" enctype="multipart/form-data" use:enhance>
<input type="file" name="avatar" accept="image/*" />
<button>Upload</button>
</form>
// +page.server.js
export const actions = {
upload: async ({ request }) => {
const data = await request.formData();
const file = data.get('avatar');
if (file.size > 5_000_000) {
return fail(413, { error: 'File too large (max 5MB)' });
}
const buffer = Buffer.from(await file.arrayBuffer());
await saveFile(buffer, file.name);
return { success: true };
}
};
Cross-Page Action Calls
Invoke actions on other pages by specifying the full path:
<form method="POST" action="/todos?/create" use:enhance>
<input name="text" />
<button>Quick Add Todo</button>
</form>
Best Practices
- Always use
fail()for validation errors rather than throwing or returning plain objects.failsets the correct HTTP status and makes the data available throughform. - Redirect after mutation with
redirect(303, '/path')to prevent form resubmission on back/refresh (Post-Redirect-Get pattern). - Preserve user input on error by returning submitted values (like
email) fromfail()and repopulating form fields. - Use
use:enhanceon all forms for a smoother experience. It gracefully degrades — the form still works without JS. - Keep actions in
+page.server.js(not+server.js). Actions are tightly coupled to pages and benefit from automaticformprop binding.
Common Pitfalls
- Forgetting
method="POST". Forms default to GET, which does not trigger actions. - Returning non-serializable data from actions. Action return values must be serializable (no functions, classes, or circular references).
- Not awaiting
update()in custom enhance. Skippingawait update()means load functions are not re-run and the page does not reflect the change. - Using
action="?/name"withdefault. A page with only adefaultaction should useaction=""or omit the attribute, notaction="?/default". - CSRF token confusion. SvelteKit handles CSRF protection automatically by checking the
Originheader. Do not implement a custom CSRF solution unless you have disabled the built-in one.
Install this skill directly: skilldb add svelte-skills
Related Skills
Component Patterns
Svelte component composition patterns including props, snippets, context, and advanced reuse techniques
Load Functions
SvelteKit server and universal load functions for fetching and passing data to pages and layouts
Reactivity
Svelte 5 runes system for fine-grained reactivity with $state, $derived, and $effect
Stores
Svelte stores and state management patterns including writable, readable, derived, and custom stores
Sveltekit Auth
Authentication patterns in SvelteKit using hooks, cookies, sessions, and OAuth flows
Sveltekit Routing
SvelteKit file-based routing system including layouts, groups, and advanced route patterns