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().

Layers of validation in a SvelteKit app Four layers from native constraints in the markup, through a Svelte action that bridges validity and reactive state, to a shared schema used by the form action, and the server response rendered back into the form. Native constraints required, type, minlength in the markup Svelte action + runes use:validate syncs validity into $state Shared Zod schema imported by component and +page.server.ts Form action fail(422, { errors, values }) form prop errors rendered, setCustomValidity, focus
Native constraints work before hydration; the action and runes add live feedback; the server action is authoritative; the form prop brings its verdict back.

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>
Svelte form with client and server validation The enhanced form runs native and action-based validation, posts to the SvelteKit form action, receives fail(422) errors in the form prop, and an effect mirrors them into custom validity and calls reportValidity. Svelte component enhance action Form action submit reportValidity() pre-check passes POST (FormData) fail(422, { errors, values }) update({ reset: false }) → form prop $effect → setCustomValidity → reportValidity
One schema and one error channel: the action's verdict arrives through the form prop and is delivered through setCustomValidity like any client-side error.

State Management and Edge Cases

Svelte 5 changes where validation state naturally lives, and each choice has a consequence.

  • Errors in $state versus native validity. Keeping a $state errors object is idiomatic, but it duplicates what validity already knows. The robust pattern is to derive visible messages from validity (as the action does) and keep $state only for values and touched flags.
  • $effect timing. Effects run after the DOM updates, which is exactly when setCustomValidity should run for server errors. Avoid $effect.pre for validation; the DOM may not reflect the new values yet.
  • Server errors clearing. A server error set with setCustomValidity persists until cleared. Clear it on the field’s next input — the action’s run() 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.
Errors in $state versus errors from native validity Two columns comparing Svelte form validation that keeps errors in a $state object with validation that derives visible errors from each input's native validity. Errors in $state • errors = $state({}) updated by hand ✓ idiomatic and easy to render ✗ duplicates native constraints ✗ easy to forget clearing paths Derived from validity • use:validate reads node.validity ✓ one source of truth ✓ reportValidity sees every rule • slightly more DOM-oriented code
Native validity is already reactive to every input; mirroring it avoids a second source of truth that can drift.

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.

Hand-written Svelte validation versus Superforms A table comparing hand-written validation with Svelte actions and runes against the Superforms library on common needs. Hand-written actions Superforms Shared client/server schema manual import ✓ adapters Native constraints from schema ✗ No ✓ constraints Nested objects and arrays manual ✓ built in Tainted / dirty tracking manual ✓ built in Bundle size ✓ minimal moderate
Superforms saves wiring on larger apps; hand-written actions stay lighter for a few simple forms.

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.

← Back to Framework Integration Patterns

Explore This Section