SvelteKit Form Actions Validation
How do you validate a SvelteKit form so that it works without JavaScript, returns errors and the user’s values from the server with a proper 422 status, shows each error beside its field, and — once the page has hydrated — submits without a reload while still moving focus to the first problem? This recipe writes a form action that parses FormData with a shared Zod schema and returns fail(422, …), renders the result from the page’s form prop, enhances the form with use:enhance, and mirrors errors into setCustomValidity() so the Constraint Validation API delivers focus and announcements the same way as the site’s canonical novalidate plus reportValidity() pattern.
When to Use SvelteKit Form Actions
Form actions are SvelteKit’s built-in answer to mutations, and they are the right place to validate any form in a SvelteKit app that submits to the server. They are especially suited to:
- Progressive enhancement by default, since a
<form method="POST">posts to the action with no JavaScript at all. - Pages that own their data, where the
+page.server.tsfile holds both theloadfunction and theactions. - Several forms on one page, via named actions such as
?/loginand?/register.
For richer client-side behaviour — live validation, tainted-field tracking, nested data — the Superforms library builds on exactly this mechanism, covered in SvelteKit Superforms with Zod. The framework-neutral model is in server actions and progressive enhancement.
Minimal Working Form Action and Page
// src/lib/validation/register.ts — shared schema
import { z } from "zod";
export const registerSchema = z.object({
name: z.string().trim().min(1, "Enter your name."),
email: z.string().trim().email("Enter an email address like name@example.com."),
password: z.string().min(12, "Password must be at least 12 characters."),
});
export type RegisterErrors = Partial<Record<keyof z.input<typeof registerSchema>, string>>;
// src/routes/register/+page.server.ts
import { fail, redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
import { registerSchema, type RegisterErrors } from "$lib/validation/register";
import { users } from "$lib/server/users";
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const values = { name: String(formData.get("name") ?? ""), email: String(formData.get("email") ?? "") };
const parsed = registerSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const errors: RegisterErrors = {};
for (const issue of parsed.error.issues) errors[issue.path[0] as keyof RegisterErrors] ??= issue.message;
return fail(422, { errors, values });
}
if (await users.exists(parsed.data.email)) {
return fail(422, { errors: { email: "An account with this email already exists." } as RegisterErrors, values });
}
await users.create(parsed.data);
redirect(303, "/welcome");
},
} satisfies Actions;
<!-- src/routes/register/+page.svelte -->
<script lang="ts">
import { enhance } from "$app/forms";
import { tick } from "svelte";
import type { ActionData } from "./$types";
let { form }: { form: ActionData } = $props();
let formEl: HTMLFormElement;
let pending = $state(false);
const fields = [
{ name: "name", label: "Full name", type: "text", autocomplete: "name" },
{ name: "email", label: "Email address", type: "email", autocomplete: "email" },
{ name: "password", label: "Password", type: "password", autocomplete: "new-password" },
] as const;
// Mirror server errors into native validity, then let the browser focus and announce.
async function reportServerErrors() {
await tick();
for (const el of formEl.querySelectorAll<HTMLInputElement>("input[name]")) {
el.setCustomValidity(form?.errors?.[el.name as keyof typeof form.errors] ?? "");
}
if (form?.errors && Object.keys(form.errors).length) formEl.reportValidity();
}
</script>
<form method="POST" novalidate bind:this={formEl} aria-busy={pending}
use:enhance={({ cancel }) => {
if (!formEl.reportValidity()) return cancel(); // native pre-check, no request
pending = true;
return async ({ update }) => {
await update({ reset: false }); // keep typed values
pending = false;
await reportServerErrors();
};
}}>
{#each fields as f}
<div class="field">
<label for={f.name}>{f.label}</label>
<input id={f.name} name={f.name} type={f.type} autocomplete={f.autocomplete} required
minlength={f.name === "password" ? 12 : undefined}
value={f.name === "password" ? "" : form?.values?.[f.name] ?? ""}
aria-invalid={form?.errors?.[f.name] ? "true" : undefined}
aria-describedby={form?.errors?.[f.name] ? `${f.name}-err` : undefined}
oninput={(e) => e.currentTarget.setCustomValidity("")} />
{#if form?.errors?.[f.name]}<p id="{f.name}-err" class="field-error">{form.errors[f.name]}</p>{/if}
</div>
{/each}
<button type="submit">{pending ? "Creating account…" : "Create account"}</button>
</form>
use:enhance receives a submit function whose cancel() stops the request; calling the browser’s reportValidity() there gives instant native feedback for required, type="email" and minlength before any network traffic. update({ reset: false }) is important: the default update() resets the form after a successful-looking response, which on some flows wipes the user’s input.
Form Action Option Reference
| Item | Type | Purpose | Notes |
|---|---|---|---|
actions.default / named actions |
Actions |
POST handlers for the page | Named actions: action="?/register" |
fail(status, data) |
helper | Return errors without throwing | Use 422 for validation |
redirect(303, url) |
helper | Navigate after success | Throws in SvelteKit 2; call outside try/catch |
form prop |
ActionData |
Latest action result on the page | null before any submission |
use:enhance |
action | Progressive enhancement | Callback can cancel() and customise update |
update({ reset }) |
function | Apply result to the page | reset: false keeps input values |
$page.form |
store/state | Same data outside the page component | Useful in layouts |
Verification Steps
import { test, expect } from "@playwright/test";
test("fail(422) keeps values and shows errors", async ({ page }) => {
await page.goto("/register");
await page.getByLabel("Full name").fill("Ada Lovelace");
await page.getByLabel("Email address").fill("taken@example.com");
await page.getByLabel("Password").fill("correct horse battery staple");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText("An account with this email already exists.")).toBeVisible();
await expect(page.getByLabel("Full name")).toHaveValue("Ada Lovelace");
await expect(page.getByLabel("Email address")).toBeFocused();
});
Edge Cases and Failure Modes
Returning plain objects for errors. A plain return from an action has status 200 and is treated as success, so use:enhance’s default update() resets the form. Always return fail(422, …) for validation errors.
redirect inside try/catch. In SvelteKit 2, redirect() throws. Catching it turns a successful registration into an error. Keep it outside try blocks, or re-throw non-Error values.
Secrets in form data. The object passed to fail() is serialised into the page. Never include the password, tokens or card data in values.
Several forms, one form prop. With named actions, the form prop holds the result of whichever action ran last. Include an identifier (form?.action === "login") in the returned data, or scope each form’s errors under its own key, so errors do not appear on the wrong form.
Named Actions for Several Forms on One Page
A settings page might have separate forms for profile, password and email preferences. Named actions keep their validation independent, and returning the action name with the result lets each form render only its own errors.
export const actions = {
profile: async ({ request }) => {
const parsed = profileSchema.safeParse(Object.fromEntries(await request.formData()));
if (!parsed.success) return fail(422, { action: "profile" as const, errors: toErrors(parsed.error) });
await saveProfile(parsed.data);
return { action: "profile" as const, saved: true };
},
password: async ({ request }) => {
const parsed = passwordSchema.safeParse(Object.fromEntries(await request.formData()));
if (!parsed.success) return fail(422, { action: "password" as const, errors: toErrors(parsed.error) });
await changePassword(parsed.data);
return { action: "password" as const, saved: true };
},
} satisfies Actions;
<form method="POST" action="?/password" use:enhance novalidate>
{#if form?.action === "password" && form.saved}<p role="status">Password updated.</p>{/if}
<!-- fields read form?.action === "password" ? form.errors : undefined -->
</form>
Success messages use role="status" and are announced politely; errors are delivered by focus through reportValidity(). Mixing the two — announcing errors in a live region and moving focus — makes screen readers read the same message twice, a problem discussed in when to use toast vs inline errors.
Because the form prop is replaced wholesale on every submission, there is no need to clear old errors by hand after a resubmission: the next fail() or success result simply takes its place. What does need clearing is the native custom validity set from the previous result, which the oninput handler and the reportServerErrors sweep take care of — otherwise a field fixed by the user would still block the next native pre-check.
Adding Live Validation Without Losing Server Authority
Form actions only validate on submit. For fields that benefit from earlier feedback, add client-side checks from the same schema on blur, setting custom validity and rendering the message, while leaving the action as the final authority. Svelte 5’s runes make this straightforward: keep a $state object of client errors keyed by field, fill it on blur from registerSchema.shape[field].safeParse(value), and display clientErrors[f.name] ?? form?.errors?.[f.name]. The timing rules — first verdict on blur, clearing on input — are those in validating on blur versus on input, and the runes-based component pattern is covered in Svelte 5 runes form validation.
Frequently Asked Questions
How do I return validation errors from a SvelteKit form action?
Return fail(422, { errors, values }) from the action. The page component receives it through its form prop, so you can render each error next to its field and restore the typed values.
Does a SvelteKit form action work without JavaScript?
Yes. A form with method POST submits natively to the page's action; the page re-renders with the form prop populated. use:enhance only improves the experience once JavaScript has loaded.
Why does my form reset after a validation error with use:enhance?
The default update() resets forms on non-failure results. Return fail() for validation errors, and call update({ reset: false }) in your enhance callback if you want inputs kept in every case.
How do I handle several forms on one SvelteKit page?
Use named actions such as ?/profile and ?/password, include the action name in the returned data, and render errors only for the form whose action produced them.
Related Guides
- Server Actions and Progressive Enhancement — the framework-neutral model.
- Svelte Form Validation — client-side validation in Svelte components.
- SvelteKit Superforms with Zod — a library built on form actions.
- Progressive Enhancement Without JavaScript — the plain-HTML baseline.