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.ts file holds both the load function and the actions.
  • Several forms on one page, via named actions such as ?/login and ?/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.

SvelteKit form action lifecycle A form posts to a named action in +page.server.ts, which validates with Zod and either redirects or returns fail(422) with errors and values; the page renders them from the form prop, with or without use:enhance. <form method= "POST"> action="?/ register" actions.register request.formData() Zod safeParse shared schema fail(422, …) errors + values form prop render errors, restore values
fail() keeps the user on the page with a 422 status; the form prop carries errors and values into the component on both the enhanced and the native path.

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.

Enhanced submission with use:enhance The enhanced form runs the native pre-check, posts with fetch, receives a fail(422) result, updates the form prop without resetting inputs, and mirrors errors into native validity. Enhanced form SvelteKit client Form action reportValidity() pre-check submit (FormData) POST ?/default fail(422, { errors, values }) update({ reset: false }) → form prop setCustomValidity + reportValidity
With use:enhance the page never reloads, but the action and the form prop behave exactly as they do on the native path.

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.

Form prop across submissions The form prop starts null, becomes a failure object with errors and values after a fail(422), becomes a success object after a successful named action, and resets to null after a redirect. null (no submission) fail(422) data success data redirected invalid submit valid named action next invalid submit valid default action
The form prop always reflects the latest action result on this page; a redirect clears it by leaving the page.

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.

← Back to Server Actions and Progressive Enhancement