Svelte Form Validation
Svelte’s approach to forms is closer to the platform than most frameworks: bind:value keeps state and inputs in sync, actions (use:) attach behaviour directly to DOM nodes, and SvelteKit’s form actions post real HTML forms that work before any JavaScript loads. That makes Svelte a natural fit for the site’s baseline — a <form novalidate> driven by the Constraint Validation API with one manual reportValidity() — rather than a reason to abandon it. The problems teams hit are specific: runes-era reactivity ($state, $derived, $effect) replacing Svelte 4 stores, deciding whether errors live in component state or in native validity, sharing a schema between a +page.server.ts action and the component, and making server errors from fail() land on the right fields with focus. This topic maps those choices, from plain Svelte 5 components through SvelteKit form actions to Superforms, the most widely used form library in the ecosystem.
The pain point is duplication: a component that validates with its own $state error object, a form action that validates with a different set of rules, and native required attributes that neither of them consults. The fix is to give each layer one job — markup declares constraints, a small action or rune-based helper turns validity into visible messages, the shared schema defines the rules, and the form action enforces them — and to connect them through the one channel every layer already understands: each input’s validity, set with setCustomValidity() and reported with reportValidity().
Prerequisites for Svelte Form Validation
| Requirement | Minimum version | Why it is needed |
|---|---|---|
| Svelte | 5.0+ | Runes: $state, $derived, $effect, $props |
| SvelteKit | 2.0+ | Form actions, fail(), use:enhance |
| TypeScript | 5.0+ | Typed ActionData and schemas |
| Zod or Valibot | Zod 3.23+ / Valibot 1.0+ | Shared schema for component and action |
| sveltekit-superforms | 2.x (optional) | Library-managed form state, errors and constraints |
| Constraint Validation API | All browsers | Focus and announcement via reportValidity() |
Svelte Form Validation API Reference
| API | Kind | Use | Notes |
|---|---|---|---|
bind:value |
directive | Two-way input binding | Works with $state in Svelte 5 |
$state / $derived |
runes | Field values and derived errors | Replace writable/derived stores |
$effect |
rune | Side effects such as setCustomValidity |
Runs after DOM updates |
use:action |
directive | Attach DOM behaviour (validation, focus) | Returns update / destroy |
onsubmit |
event attribute | Intercept submission | Svelte 5 uses event attributes, not on:submit |
enhance |
SvelteKit action | Progressive enhancement of form posts | cancel(), update({ reset }) |
fail(status, data) |
SvelteKit | Return errors from actions | 422 for validation |
form prop / page.form |
SvelteKit | Latest action result | Render errors and values |
Step-by-Step Implementation
1. Keep native constraints in the markup
<script lang="ts">
let email = $state("");
let password = $state("");
</script>
<form method="POST" novalidate>
<label for="email">Email address</label>
<input id="email" name="email" type="email" required autocomplete="email" bind:value={email}
aria-describedby="email-err" />
<p id="email-err" class="field-error" hidden></p>
<label for="password">Password</label>
<input id="password" name="password" type="password" required minlength="12"
autocomplete="new-password" bind:value={password} aria-describedby="password-err" />
<p id="password-err" class="field-error" hidden></p>
<button type="submit">Create account</button>
</form>
The attributes do real work before hydration and remain the cheapest validation there is. Every later layer reads from them rather than duplicating them.
2. Bridge native validity into the component with an action
// lib/actions/validate.ts
import type { Action } from "svelte/action";
type Options = { rule?: (value: string) => string; show?: () => boolean };
/** Keeps a field's custom validity and visible message in sync; works for any input. */
export const validate: Action<HTMLInputElement, Options | undefined> = (node, options = {}) => {
let opts = options;
const out = document.getElementById(`${node.id}-err`);
function run(): void {
node.setCustomValidity("");
const custom = opts.rule?.(node.value) ?? "";
if (custom) node.setCustomValidity(custom);
const show = (opts.show?.() ?? true) && node.dataset.touched === "true";
const message = node.validity.valid ? "" : node.validationMessage;
node.toggleAttribute("aria-invalid", show && Boolean(message));
if (out) { out.textContent = show ? message : ""; out.hidden = !show || !message; }
}
const onBlur = () => { node.dataset.touched = "true"; run(); };
node.addEventListener("input", run);
node.addEventListener("blur", onBlur);
run();
return {
update(next) { opts = next ?? {}; run(); },
destroy() { node.removeEventListener("input", run); node.removeEventListener("blur", onBlur); },
};
};
<input id="password" name="password" type="password" required minlength="12"
use:validate={{ rule: (v) => (/\s$/.test(v) ? "Remove the space at the end." : "") }} />
An action keeps validation logic next to the DOM node it concerns, runs without extra component state, and cleans up when the node leaves. Because it uses setCustomValidity(), the form’s reportValidity() on submit sees custom rules and native constraints together. The runes-only alternative, with errors in $state, is covered in Svelte 5 runes form validation.
3. Validate on the server with the same schema
// src/lib/schemas/signup.ts
import { z } from "zod";
export const signupSchema = z.object({
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."),
});
// src/routes/signup/+page.server.ts
import { fail, redirect } from "@sveltejs/kit";
import { signupSchema } from "$lib/schemas/signup";
export const actions = {
default: async ({ request }) => {
const data = Object.fromEntries(await request.formData());
const parsed = signupSchema.safeParse(data);
if (!parsed.success) {
const errors = Object.fromEntries(parsed.error.issues.map((i) => [i.path[0], i.message]));
return fail(422, { errors, values: { email: String(data.email ?? "") } });
}
await createAccount(parsed.data);
redirect(303, "/welcome");
},
};
The full form-action recipe, including use:enhance and named actions, is in SvelteKit form actions validation.
4. Bring server errors back into native validity
<script lang="ts">
import { enhance } from "$app/forms";
import { tick } from "svelte";
let { form } = $props();
let formEl: HTMLFormElement;
$effect(() => {
// Runs after the DOM reflects the new `form` prop.
const errors = form?.errors ?? {};
for (const el of formEl.querySelectorAll<HTMLInputElement>("input[name]")) {
el.setCustomValidity(errors[el.name] ?? "");
}
if (Object.keys(errors).length) tick().then(() => formEl.reportValidity());
});
</script>
<form method="POST" novalidate bind:this={formEl}
use:enhance={({ cancel }) => {
if (!formEl.reportValidity()) return cancel();
return async ({ update }) => update({ reset: false });
}}>
<!-- fields with value={form?.values?.email ?? ""} -->
</form>
State Management and Edge Cases
Svelte 5 changes where validation state naturally lives, and each choice has a consequence.
- Errors in
$stateversus native validity. Keeping a$stateerrors object is idiomatic, but it duplicates whatvalidityalready knows. The robust pattern is to derive visible messages from validity (as the action does) and keep$stateonly for values and touched flags. $effecttiming. Effects run after the DOM updates, which is exactly whensetCustomValidityshould run for server errors. Avoid$effect.prefor validation; the DOM may not reflect the new values yet.- Server errors clearing. A server error set with
setCustomValiditypersists until cleared. Clear it on the field’s nextinput— the action’srun()resets custom validity on every input, which handles this automatically. - Keyed each blocks for repeating rows. Use a stable id as the key in
{#each rows as row (row.id)}, or inputs and their errors swap between rows when one is removed, the problem described in dynamic and repeating fields.
Accessibility Compliance in Svelte Forms
Svelte compiles to direct DOM operations, so accessibility depends entirely on the markup you write — the compiler’s a11y warnings help but do not catch validation-specific issues. Every field needs a <label for> (Svelte warns about missing labels), an error element referenced by aria-describedby, and aria-invalid toggled in step with the visible message. Conditional rendering with {#if error} is fine for the message, but keep the aria-describedby reference stable by rendering an empty, hidden element rather than removing it — some screen readers do not re-read descriptions whose target appears later. Focus on failed submit should come from reportValidity(), which moves to the first invalid field in document order; for several errors, pair it with an accessible error summary.
Common Gotchas and Debugging
on:submit in Svelte 5 code. Svelte 5 uses event attributes (onsubmit), and mixing old directive syntax with runes in the same component fails to compile or behaves inconsistently. Use onsubmit={handler} with runes.
Binding numbers from text inputs. bind:value on type="number" gives a number (or null), but on type="text" inputmode="numeric" it gives a string. Coerce in the schema, not in the binding, so the component and the server action interpret the value identically.
use:enhance resetting the form. The default update() resets inputs after a successful-looking result. Pass { reset: false } when you need values kept, and return fail() for validation errors so the default does not treat them as success.
Forgetting name on bound inputs. bind:value keeps component state in sync, but only inputs with a name attribute reach FormData and the form action. A field that validates perfectly in the component and arrives empty on the server is almost always missing name.
Effects that loop. An $effect that sets state it also reads (for example writing an errors object it depends on) re-runs forever. Derive errors with $derived and use effects only for DOM side effects like setCustomValidity.
Asynchronous Checks in Svelte Components
Availability checks and other server lookups fit naturally into the action pattern: the action owns the debounce timer and the AbortController, sets a “Checking…” custom validity while the request is pending so a fast submit cannot slip through, and writes the verdict with setCustomValidity() when it arrives. Because the action is tied to the node, its destroy hook aborts any request in flight when the field is removed — a cleanup that component-level $effect code often forgets. Keep the pending indicator beside the input rather than replacing it, so focus is never lost during the check, as explained in restoring focus after async validation.
export const available: Action<HTMLInputElement, { url: (v: string) => string }> = (node, { url }) => {
let ctrl: AbortController | undefined;
let timer: ReturnType<typeof setTimeout>;
const onInput = () => {
ctrl?.abort();
clearTimeout(timer);
if (!node.value || !node.checkValidity()) return;
node.setCustomValidity("Checking availability…");
timer = setTimeout(async () => {
ctrl = new AbortController();
try {
const res = await fetch(url(node.value), { signal: ctrl.signal });
node.setCustomValidity((await res.json()).available ? "" : "That username is taken.");
} catch (e) {
if ((e as DOMException).name !== "AbortError") node.setCustomValidity(""); // fail open
}
}, 400);
};
node.addEventListener("input", onInput);
return { destroy() { ctrl?.abort(); clearTimeout(timer); node.removeEventListener("input", onInput); } };
};
Moving From Svelte 4 Patterns
Many Svelte codebases still carry Svelte 4 form code: export let props, $: reactive statements computing errors, writable stores for form state, and on:submit|preventDefault. In Svelte 5 the equivalents are $props(), $derived for computed errors, $state for mutable values, and onsubmit with an explicit event.preventDefault(). The migration is mostly mechanical, but two validation behaviours change. $: statements re-ran on any referenced variable change, sometimes computing errors before a field was touched; $derived is equally eager, so keep the touched-or-submitted gate explicit when deriving visible errors. And store subscriptions that set setCustomValidity must become $effects, which run after DOM updates — the correct timing, but a change if your Svelte 4 code relied on synchronous store callbacks. The runes-based form is shown in full in Svelte 5 runes form validation.
Building Reusable Field Components
A design-system Field.svelte that wraps label, input, hint and error removes most of the accessibility boilerplate from every form. Give it one required prop — the field name — and derive id, the error element’s id and aria-describedby from it, so a product team cannot forget the wiring. Accept the native constraint attributes (required, minlength, pattern, type) and pass them straight through to the input, and apply the validation action inside the component. Server errors can arrive through a prop and be applied with setCustomValidity in an $effect, while the error text renders from validationMessage. The result is a component where native validation, custom rules and server errors all flow through one channel, and where every instance has correct labelling by construction.
<!-- Field.svelte -->
<script lang="ts">
import { validate } from "$lib/actions/validate";
let { name, label, serverError = "", ...rest } = $props();
let input: HTMLInputElement;
$effect(() => { input.setCustomValidity(serverError); });
</script>
<div class="field">
<label for={name}>{label}</label>
<input id={name} {name} bind:this={input} aria-describedby="{name}-err" use:validate {...rest} />
<p id="{name}-err" class="field-error" hidden></p>
</div>
Multi-Step Flows in SvelteKit
SvelteKit’s routing makes multi-step forms straightforward to build as separate pages, each with its own form action that validates only that step’s fields and stores the accumulated data server-side — in a session or a draft record — before redirecting to the next step. This keeps every step working without JavaScript and means the browser’s back button naturally revisits earlier steps. The final step’s action must validate the complete data again with the full schema, because steps can be revisited and edited. On the client, each step’s component can pre-validate with the step’s slice of the schema (signupSchema.pick({ email: true, password: true })), giving instant feedback without duplicating rules. The step-level validation rules and their accessibility are covered in validating multi-step forms per step.
Localising Validation Messages in Svelte
Messages from the schema, from native constraints and from the server should all come from one catalogue in the page’s language. In SvelteKit, the locale is usually known on the server (from the URL or a cookie) and passed to the page through load; pass it into a message function that the schema’s refinements and the validation action both use. Native validationMessage text is localised by the browser into the browser’s language, which may differ from the page’s, so map native flags to catalogue messages in the action instead of displaying validationMessage directly on multilingual sites. Returning message keys rather than rendered strings from form actions lets the component render them in the right language even when one action serves several locales, the approach laid out in keeping client and server error messages in sync.
Superforms: When to Use a Library
Superforms (sveltekit-superforms) packages everything above: it validates with Zod, Valibot or other adapters on both server and client, keeps values, errors and “tainted” state in stores, adds native constraint attributes derived from the schema, handles nested data and arrays, and integrates with use:enhance. It is the right choice for larger apps with many forms or complex nested data; the hand-written action approach is lighter for a few simple forms. Either way, the accessibility responsibilities stay with you — Superforms gives you error strings, but rendering them with aria-describedby, aria-invalid and focus is your markup’s job. The Superforms recipe is SvelteKit Superforms with Zod.
Testing Svelte Forms
Test Svelte forms at two levels. Component tests with Vitest and @testing-library/svelte render the component, type into fields and assert on aria-invalid and message text — useful for the action and effect logic. Browser tests with Playwright cover the full SvelteKit loop, including the form action, fail() responses and the no-JavaScript path, which component tests cannot reach. Run the Playwright suite with JavaScript disabled once to prove the form action and server-rendered errors work on their own; the approach is detailed in testing form error messages with Playwright.
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import Signup from "./Signup.svelte";
it("shows the email error after blur", async () => {
render(Signup);
const email = screen.getByLabelText("Email address");
await userEvent.type(email, "not-an-email");
await userEvent.tab();
expect(email).toHaveAttribute("aria-invalid", "true");
expect(screen.getByText(/email address/i, { selector: "#email-err" })).toBeVisible();
});
Browser Compatibility Matrix
| Feature | Chromium | Firefox | Safari | Notes |
|---|---|---|---|---|
| Svelte 5 compiled output | Yes | Yes | Yes | ES2020+ |
use:enhance (fetch + FormData) |
Yes | Yes | Yes | Falls back to native post |
setCustomValidity in actions |
Yes | Yes | Yes | — |
:user-invalid styling |
119+ | 88+ | 16.5+ | Style errors after interaction |
Frequently Asked Questions
How should I validate forms in Svelte 5?
Keep native constraints in the markup, attach a Svelte action that syncs each input's validity and custom rules into visible messages, validate again in the SvelteKit form action with a shared schema, and call reportValidity on submit for focus and announcement.
Should validation errors live in $state?
You can, but deriving messages from each input's native validity avoids a second source of truth. Use $state for values and touched flags, and $derived or actions for errors.
How do I show errors returned by a SvelteKit form action?
Return fail(422, { errors, values }) from the action, read the form prop in the page, and in an $effect set each field's custom validity from form.errors, then call reportValidity.
Do I need Superforms?
Not for a few simple forms; a small validation action and a shared schema are enough. Superforms helps in larger apps with many forms, nested data and tainted-state tracking.
Related Guides
- SvelteKit Superforms with Zod — the library approach.
- Svelte 5 Runes Form Validation — validation with $state and $derived.
- SvelteKit Form Actions Validation — the server side.
- Framework Integration Patterns — how other frameworks compare.
← Back to Framework Integration Patterns