If you’ve built web apps long enough, you’ve written some version of this nightmare: a <form> that fires a fetch, which hits an API route, which validates input, which returns a JSON response, which you then parse on the client, which updates some local state, which re-renders the UI. Six layers deep just to save a user’s name.
SvelteKit form actions collapse that entire stack into something that fits in your head. The form talks directly to the server. The server mutates data and sends back a response. The page re-validates. Done. No API client code, no JSON serialization ceremony, no separate endpoint file to maintain.
And the killer feature: it works without JavaScript. Load the page with JS disabled — the form still submits. When JS is present, you opt into a smoother experience with a single directive. That’s progressive enhancement done the way it was always supposed to work.
Official docs live at kit.svelte.dev and the SvelteKit source is on GitHub.
Why API Routes Are Overkill for Most Mutations
The SPA-era habit is to treat every mutation as a REST call: you POST to /api/users, get back {id: 1, name: "..."}, update client state, maybe invalidate a cache. That works fine — until you’re doing it for every little thing and your codebase is split across a dozen endpoint files, a fetch wrapper, a client-side store, and a form component that somehow needs to know about all of them.
SvelteKit’s form actions are a deliberate rejection of that pattern for server-rendered apps. The mental model is simple: the form’s action attribute points to a named handler in the same route’s +page.server.ts. The handler runs on the server, does whatever it needs to do, and returns data that the page can read. No separate files, no REST contract, no JSON middleware.
This isn’t a step backwards — it’s recognizing that for the 90% of mutations that are "user clicked a button, server did something, page updated," you don’t need the full SPA machinery.
The Anatomy of a Form Action
A form action lives in +page.server.ts (or .js), exported under the actions key.
// src/routes/profile/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import { db } from '$lib/db';
export const load: PageServerLoad = async ({ locals }) => {
const user = await db.user.findUnique({ where: { id: locals.userId } });
return { user };
};
export const actions: Actions = {
// The "default" action maps to a plain <form method="POST">
// Named actions map to <form method="POST" action="?/updateProfile">
updateProfile: async ({ request, locals }) => {
const data = await request.formData();
const name = data.get('name')?.toString().trim();
const bio = data.get('bio')?.toString().trim();
// Validate before touching the database
if (!name || name.length < 2) {
return fail(422, {
name,
bio,
errors: { name: 'Name must be at least 2 characters.' }
});
}
await db.user.update({
where: { id: locals.userId },
data: { name, bio }
});
// Return nothing for a simple success, or return data
return { success: true };
}
};
The fail() helper is important. It returns an HTTP 4xx response but keeps SvelteKit from treating it as an error — the page re-renders with the returned data available via form in your component. Your form stays populated, errors display inline. No client-side error state to manage.
The Form Component
<!-- src/routes/profile/+page.svelte -->
<script lang="ts">
import type { PageData, ActionData } from './$types';
// `data` comes from the load function
// `form` comes from the most recent action response
let { data, form } = $props<{ data: PageData; form: ActionData }>();
</script>
<form method="POST" action="?/updateProfile">
<label>
Name
<!-- Re-populate from form data on validation failure -->
<input
name="name"
value={form?.name ?? data.user.name}
aria-invalid={!!form?.errors?.name}
/>
{#if form?.errors?.name}
<span class="error">{form.errors.name}</span>
{/if}
</label>
<label>
Bio
<textarea name="bio">{form?.bio ?? data.user.bio}</textarea>
</label>
<button type="submit">Save</button>
</form>
Submit this form with JavaScript disabled and it does a full-page POST. The server processes it, SvelteKit re-runs the load function, the page re-renders with the new data. That’s the baseline — and it actually works.
Adding Progressive Enhancement with use:enhance
The baseline works, but a full-page reload on every form submit feels dated. use:enhance intercepts the form submission client-side, uses fetch under the hood, and updates the page without a full reload. One import, one directive.
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let saving = $state(false);
</script>
<form
method="POST"
action="?/updateProfile"
use:enhance={() => {
// Called before the request fires — perfect for loading state
saving = true;
return async ({ update }) => {
// Called after the server responds
saving = false;
// update() re-runs load() and updates the form state
await update();
};
}}
>
<label>
Name
<input name="name" value={form?.name ?? data.user.name} />
</label>
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</form>
The callback passed to use:enhance fires before submission. The function it returns fires after the server responds. You get access to result, update, formData, formElement, and cancel — enough to handle any scenario without reaching for a separate state management library.
If you pass nothing to use:enhance at all (use:enhance with no argument), it applies default behavior: no loading state, but still no page reload. Good enough for quick forms where you don’t need feedback during the request.
Handling Multiple Actions on One Page
A single page can have multiple named actions. The form’s action attribute determines which one runs.
// src/routes/settings/+page.server.ts
export const actions: Actions = {
updateEmail: async ({ request, locals }) => {
// handle email change
},
changePassword: async ({ request, locals }) => {
// handle password change
},
deleteAccount: async ({ request, locals }) => {
// handle account deletion
}
};
<!-- Two separate forms, same page -->
<form method="POST" action="?/updateEmail" use:enhance>
<input name="email" type="email" />
<button>Update Email</button>
</form>
<form method="POST" action="?/changePassword" use:enhance>
<input name="currentPassword" type="password" />
<input name="newPassword" type="password" />
<button>Change Password</button>
</form>
Each form is independent. Each action has its own validation logic. The form prop on the page reflects the result of whichever action ran last — so if you need to show errors from both forms simultaneously, you’ll need to distinguish them in the returned data.
Input Validation at Scale
Manually calling data.get() and checking lengths works for toy examples. For anything serious, wire in a validation library. Zod is the common choice.
import { z } from 'zod';
import { fail } from '@sveltejs/kit';
const profileSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
bio: z.string().max(500).optional(),
website: z.string().url('Must be a valid URL').optional().or(z.literal(''))
});
export const actions: Actions = {
updateProfile: async ({ request, locals }) => {
const raw = Object.fromEntries(await request.formData());
const parsed = profileSchema.safeParse(raw);
if (!parsed.success) {
// flatten() gives you field-level error messages
return fail(422, {
...raw,
errors: parsed.error.flatten().fieldErrors
});
}
await db.user.update({
where: { id: locals.userId },
data: parsed.data
});
}
};
The Object.fromEntries(await request.formData()) pattern collapses the FormData into a plain object that Zod can validate directly. One caveat: if your form has checkboxes or multi-select fields, formData.getAll() is what you need — Object.fromEntries will only keep the last value for duplicate keys.
Gotchas
The form prop only reflects the last action. If a user has two forms open on the same page — say, an edit form and a delete confirmation — and one action returns an error, the form prop updates for the whole page. Both forms will see the same form value. You need to namespace your action responses if both forms can show errors simultaneously.
File uploads need special handling. request.formData() gives you File objects for file inputs, but you can’t return a File from an action — it can’t be serialized. Handle file storage (local disk, S3, whatever) in the action, then return just metadata. Also set enctype="multipart/form-data" on the form or file inputs silently don’t work.
Don’t forget CSRF. SvelteKit has CSRF protection enabled by default for form submissions — it checks the Origin header. This works correctly for browser forms. If you’re hitting form actions from a script or a non-browser client, you’ll run into 403s. Don’t disable it globally; understand why it’s there.
The fail() status code matters. fail(422, data) and fail(400, data) both work, but the HTTP status propagates. Some CDNs and proxies cache 200s aggressively. Make sure your error responses actually return 4xx so they aren’t cached.
Redirecting after success. After a mutation that navigates the user somewhere else, use redirect() from @sveltejs/kit. A plain return gives back form data — a redirect actually takes the user to a new route. Don’t return form data when you intend to redirect; the data gets discarded anyway.
import { redirect } from '@sveltejs/kit';
export const actions: Actions = {
createPost: async ({ request, locals }) => {
const post = await db.post.create({ ... });
redirect(303, `/posts/${post.id}`);
}
};
Use 303 (See Other) for post-mutation redirects, not 302. 303 explicitly tells the browser to GET the redirect target, which is what you want after a POST.
Production-Ready Patterns
Rate limit your actions. Form actions are POST endpoints — they’re as exposed as any API route. Add rate limiting at the action level or via a hook. A simple in-memory rate limiter works for single-instance deployments; for anything horizontal, use Redis.
Return typed action data. The ActionData type from './$types' is inferred automatically, but make sure your fail() calls return consistent shapes. Inconsistent return types make the component code ugly and error-prone.
Optimistic updates with use:enhance. The callback receives formData before the server responds. You can update local state immediately and roll back in the return async block if the server comes back with an error. This gives you snappy UX without a separate state management layer.
<script lang="ts">
let items = $state(data.items);
function handleEnhance({ formData, cancel }) {
const id = formData.get('id');
// Optimistic remove
const previous = items;
items = items.filter(i => i.id !== id);
return async ({ result, update }) => {
if (result.type === 'error' || result.type === 'failure') {
// Roll back
items = previous;
}
await update({ reset: false });
};
}
</script>
<form method="POST" action="?/deleteItem" use:enhance={handleEnhance}>
<input type="hidden" name="id" value={item.id} />
<button>Delete</button>
</form>
Never trust client-supplied IDs for ownership checks. If the form sends ?/deleteItem with id=123, verify on the server that the authenticated user actually owns item 123 before deleting it. The form action has access to locals (populated by your hooks) — use it.
Extract shared validation logic. When multiple actions on the same page or across routes share validation, pull the Zod schema and the validation call into a $lib/validators/ file. Actions should be thin: parse request, call a service, return a result.
When Form Actions Aren’t the Right Tool
Form actions are great for mutations tied to a specific page. They’re not the right choice when:
- You need to call the mutation from multiple unrelated pages — a shared API route or a server function is cleaner.
- You’re building a heavily interactive UI where the mutation and the UI state are deeply entangled — a fetch-based approach with SvelteKit’s
invalidate()gives you more control. - You’re building a public API that non-browser clients will consume — expose a proper REST or RPC endpoint.
The heuristic: if the mutation belongs conceptually to one page (or one feature), a form action is exactly right. If it needs to be callable from anywhere, treat it like infrastructure and put it in src/lib/.
Wrapping Up
Form actions aren’t a new idea — PHP developers have been doing server-side form handling since forever. SvelteKit just brings it back with type safety, clean syntax, and the ability to progressively enhance without rewriting anything.
The pattern scales from a dead-simple contact form to a complex settings page with multiple independent mutations, validation, optimistic updates, and redirect flows. The baseline (no JS) works out of the box. The enhanced version (use:enhance) adds exactly as much complexity as you need, no more.
Stop reaching for fetch every time you need a button to do something on the server. Most of the time, a form action is the right call.