SvelteKit form actions give you forms that work before JavaScript loads and upgrade to in-place updates after — but the default use:enhance behaviour surprises people twice: a successful submit resets the form, and a failed one leaves focus wherever it was with no announcement, so keyboard and screen-reader users do not learn that anything went wrong.
This page builds a form action that returns field errors and submitted values with fail(), and a customised use:enhance callback that manages pending state, focus and announcements. It is the SvelteKit counterpart to the store-based patterns in Svelte store integration for forms, and follows the same principle as progressive enhancement for server-rendered forms: start from a form that posts and reloads, then intercept it.
Context and prerequisites
How the pieces fit:
- Actions live in
+page.server.tsasexport const actions = { default: async ({ request }) => … }. They receive theFormDataof a normal HTMLPOST. fail(status, data)returns a non-2xx result whosedatabecomes the page’sformprop. Returning an object from a successful action does the same with a 2xx.- Without JavaScript, the browser posts, SvelteKit runs the action and renders the page with
formpopulated. Errors display on a full reload. - With
use:enhance, SvelteKit intercepts submit, posts withfetch, updatesformandpage.status, and by default resets the form on success and re-runsloadfunctions (invalidation).
The customisation hook is the function you pass to use:enhance: it runs before submit and returns a callback that receives the result, where you decide whether to call the default update() and with which options.
The core pattern: an action returning errors and values, and a custom enhance
// src/routes/signup/+page.server.ts
import { fail, redirect } from "@sveltejs/kit";
import { z } from "zod";
import type { Actions } from "./$types";
const Signup = z.object({
email: z.string().trim().email("Enter an email like [email protected]."),
name: z.string().trim().min(1, "Enter your name."),
});
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const values = { email: String(formData.get("email") ?? ""), name: String(formData.get("name") ?? "") };
const parsed = Signup.safeParse(values);
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const i of parsed.error.issues) errors[String(i.path[0])] ??= i.message;
// Never echo secrets back (passwords, card numbers): omit them from values.
return fail(400, { errors, values });
}
await createAccount(parsed.data);
redirect(303, "/welcome");
},
};
declare function createAccount(d: z.infer<typeof Signup>): Promise<void>;
<!-- src/routes/signup/+page.svelte -->
<script lang="ts">
import { enhance } from "$app/forms";
import { tick } from "svelte";
let { form } = $props(); // populated by the action result
let pending = $state(false);
let summary: HTMLElement | undefined = $state();
const errors = $derived(form?.errors ?? {});
const count = $derived(Object.keys(errors).length);
function submitEnhance() {
pending = true;
return async ({ result, update }) => {
// reset: false keeps what the user typed on success paths that stay on the page.
await update({ reset: false });
pending = false;
if (result.type === "failure") {
await tick(); // wait for the summary to render
summary?.focus();
}
};
}
</script>
<form method="POST" use:enhance={submitEnhance} novalidate>
{#if count}
<div bind:this={summary} tabindex="-1" role="group" aria-labelledby="summary-title" class="error-summary">
<h2 id="summary-title">There {count === 1 ? "is 1 problem" : `are ${count} problems`}</h2>
<ul>{#each Object.entries(errors) as [field, msg]}<li><a href={`#${field}`}>{msg}</a></li>{/each}</ul>
</div>
{/if}
<label for="name">Name</label>
<input id="name" name="name" value={form?.values?.name ?? ""} aria-invalid={errors.name ? "true" : undefined}
aria-describedby={errors.name ? "name-error" : undefined} autocomplete="name" />
{#if errors.name}<p id="name-error">{errors.name}</p>{/if}
<label for="email">Email</label>
<input id="email" name="email" type="email" value={form?.values?.email ?? ""} aria-invalid={errors.email ? "true" : undefined}
aria-describedby={errors.email ? "email-error" : undefined} autocomplete="email" />
{#if errors.email}<p id="email-error">{errors.email}</p>{/if}
<button aria-disabled={pending}>{pending ? "Creating account…" : "Create account"}</button>
</form>
Step-by-step walkthrough
- Validate in the action with a schema. The action is the authority; it runs whether or not JavaScript loaded.
- Return
fail(400, { errors, values }). Errors keyed by field name, and the submitted values (minus secrets) so the page can re-populate inputs on both the no-JS and enhanced paths. - Render inputs from
form?.values. Usingvalue={form?.values?.email ?? ""}makes the no-JS reload show what the user typed. - Customise
use:enhance. Setpendingbefore submit, callupdate({ reset: false })in the callback, and clearpendingafter. - Move focus after a failure. Wait for the DOM with
tick(), then focus the error summary, whose links jump to each field — the pattern in building an accessible error summary. - Redirect on success with 303. A redirect after POST prevents resubmission on reload; with
use:enhance, SvelteKit follows it client-side.
Why the no-JavaScript path still matters
It is tempting to treat the non-enhanced path as a legacy fallback. In practice it runs more often than expected: while the page is still hydrating on a slow phone, when a script fails to load behind a corporate proxy, when a browser extension throws during startup, and for every user who submits within the first second of a server-rendered page appearing. Designing the action so that the full-reload response is complete — errors, values, summary — means those users get a working form instead of a form that silently does nothing. It also makes the action easy to test with a plain HTTP request, independent of any client code.
Failure modes and edge cases
1. Inputs cleared after a successful save on an edit page
On success, update() resets the form, which restores each input’s defaultValue — the value it had at server render, not the saved one. Use update({ reset: false }) on edit forms, or re-render with the saved values returned from the action.
2. Echoing passwords in values
Returning the whole FormData as values puts passwords and card numbers into the rendered HTML on the no-JS path and into client state. Build values from an allow-list of safe fields.
3. Double submission
Buttons remain clickable during the request. Guard in the enhance function (if (pending) return cancel() using the cancel argument it receives) and make the action idempotent, per handling double submit and idempotency.
4. Client-side validation on top
For instant feedback, add a client schema check on blur; keep the action as the authority. Libraries such as Superforms automate the sharing, covered in validating SvelteKit forms with Superforms and Zod.
5. Files and large bodies
File inputs require enctype="multipart/form-data"; SvelteKit parses them into File objects in request.formData(). Server body limits (the adapter’s BODY_SIZE_LIMIT) apply, and exceeding them fails before your action runs — validate size on the client too.
Verification checklist
Frequently Asked Questions
Why 303 for the redirect after a successful action?
303 See Other tells the browser to follow with a GET, so reloading the destination page does not re-POST the form. SvelteKit’s redirect defaults are designed around this; use 303 for post-action redirects.
Can I use named actions for multiple forms on one page?
Yes. Export actions = { create, delete } and point each form at ?/create or ?/delete. The returned form prop is shared, so include which action produced the result (for example an action: "create" field) so each form renders only its own errors.
Does use:enhance work with Svelte 4 syntax?
Yes; the action API is the same. The example uses Svelte 5 runes ($props, $state, $derived) for component state, but export let form and let pending = false work identically in Svelte 4.