SvelteKit Superforms with Zod

How do you get one Zod schema to validate a SvelteKit form on the server and the client, populate native constraint attributes automatically, keep values across failed submissions, track which fields the user changed, and still render errors accessibly with focus on the first problem? Superforms (sveltekit-superforms) does most of this: superValidate runs the schema in load and in form actions, the client-side superForm keeps values, errors and constraints in stores that integrate with use:enhance, and the zod adapter bridges the schema. This recipe wires it end to end, then adds the pieces Superforms leaves to you — aria-describedby, aria-invalid and focus — so errors reach every user through the same channel the Constraint Validation API uses.

When to Use Superforms

Superforms earns its dependency when forms are numerous or complex:

  • Many forms across an app, where hand-wiring fail(), values and errors for each one repeats boilerplate.
  • Nested data and arrays — addresses, line items — which Superforms handles with dotted paths and array helpers.
  • Tainted-state needs — warning about unsaved changes, sending only changed fields.
  • Client-side validation from the same schema, without writing an adapter yourself.

For two or three simple forms, the hand-written approach in Svelte form validation and SvelteKit form actions validation is lighter and has no library to learn.

Superforms data flow The page load creates an empty validated form, the component renders it through superForm stores, the form posts to an action that calls superValidate again, and the result flows back into the same stores. load: superValidate( zod(schema)) empty form + constraints Component: superForm(data. form) $form, $errors, $constraints POST to action use:enhance from superForm action: superValidate( request, zod(schema)) fail(400, { form }) on error Back to stores errors rendered, values kept
The same schema runs in load, in the action and optionally on the client; the stores are the single place the component reads values, errors and constraints.

Minimal Working Superforms Setup

// src/lib/schemas/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
  name: z.string().trim().min(1, "Enter your name."),
  email: z.string().trim().email("Enter an email address like name@example.com."),
  message: z.string().trim().min(20, "Your message must be at least 20 characters.").max(2000),
});
// src/routes/contact/+page.server.ts
import { superValidate, message } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import { fail } from "@sveltejs/kit";
import { contactSchema } from "$lib/schemas/contact";

export const load = async () => ({ form: await superValidate(zod(contactSchema)) });

export const actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, zod(contactSchema));
    if (!form.valid) return fail(400, { form });                 // errors + values travel back
    await deliverMessage(form.data);
    return message(form, "Thanks — we'll reply within 2 working days.");
  },
};
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
  import { superForm } from "sveltekit-superforms";
  import { zodClient } from "sveltekit-superforms/adapters";
  import { contactSchema } from "$lib/schemas/contact";

  let { data } = $props();
  const { form, errors, constraints, message, enhance, submitting } = superForm(data.form, {
    validators: zodClient(contactSchema),          // same schema on the client
    validationMethod: "onblur",                    // first verdict on blur
    errorSelector: '[aria-invalid="true"]',        // focus target after a failed submit
    scrollToError: "smooth",
  });

  const fields = [
    { name: "name", label: "Your name", type: "text", autocomplete: "name" },
    { name: "email", label: "Email address", type: "email", autocomplete: "email" },
  ] as const;
</script>

{#if $message}<p role="status" class="form-status">{$message}</p>{/if}

<form method="POST" novalidate use:enhance aria-busy={$submitting}>
  {#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}
             bind:value={$form[f.name]} {...$constraints[f.name]}
             aria-invalid={$errors[f.name] ? "true" : undefined}
             aria-describedby={$errors[f.name] ? `${f.name}-err` : undefined} />
      {#if $errors[f.name]}<p id="{f.name}-err" class="field-error">{$errors[f.name][0]}</p>{/if}
    </div>
  {/each}

  <div class="field">
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" bind:value={$form.message} {...$constraints.message}
              aria-invalid={$errors.message ? "true" : undefined}
              aria-describedby={$errors.message ? "message-err" : undefined}></textarea>
    {#if $errors.message}<p id="message-err" class="field-error">{$errors.message[0]}</p>{/if}
  </div>

  <button type="submit" aria-disabled={$submitting}>{$submitting ? "Sending…" : "Send message"}</button>
</form>

$constraints spreads native attributes derived from the schema — required, minlength, maxlength, type hints — onto each input, so the no-JavaScript path and the browser’s own checks match the Zod rules. The errorSelector option tells Superforms which element to focus after a failed submit; pointing it at [aria-invalid="true"] means focus lands on the first field that the markup marks invalid, keeping focus behaviour tied to what users and assistive technology can perceive.

Failed submission with Superforms The enhanced form posts to the action, superValidate fails, the action returns fail(400, { form }), Superforms updates the stores, the component renders errors with aria attributes, and focus moves to the first invalid field. Component Superforms client Form action submit (client validators pass or fail) POST FormData fail(400, { form }) with errors $errors, $form updated aria-invalid rendered → focus first [aria-invalid="true"]
Superforms moves data and errors; the markup's aria attributes and the errorSelector make the result perceivable and focus the right field.

Superforms Option Reference

Option / API Where Purpose Notes
superValidate(zod(schema)) load Empty form with defaults and constraints No errors shown on first load
superValidate(request, zod(schema)) action Parse and validate the POST form.valid, form.errors, form.data
fail(400, { form }) action Return errors and values Superforms uses 400 by convention; 422 also works
message(form, text) action Form-level status Render in role="status"
validators: zodClient(schema) client Client-side validation Same schema, no server round trip
validationMethod client auto, oninput, onblur, submit-only onblur suits most fields
$constraints client Native attributes from the schema Spread onto inputs
errorSelector, scrollToError client Focus and scroll after failure Point at [aria-invalid="true"]
$tainted / isTainted client Unsaved-change tracking For navigation warnings

Verification Steps

import { test, expect } from "@playwright/test";

test("superforms errors are accessible and focused", async ({ page }) => {
  await page.goto("/contact");
  await page.getByLabel("Email address").fill("ada@");
  await page.getByRole("button", { name: "Send message" }).click();
  await expect(page.getByLabel("Your name")).toBeFocused();                  // first invalid field
  await expect(page.getByLabel("Your name")).toHaveAttribute("aria-describedby", "name-err");
  await expect(page.locator("#email-err")).toHaveText("Enter an email address like name@example.com.");
  await expect(page.getByLabel("Message")).toHaveAttribute("minlength", "20");
});

Edge Cases and Failure Modes

Rendering all errors versus the first. $errors.field is an array; showing every message for one field makes long, repetitive text. Show the first, as above, or join them if they are genuinely independent.

Custom error focus. Without errorSelector, Superforms focuses based on its own heuristics, which may not match your markup. Setting it explicitly keeps focus aligned with aria-invalid.

Nested fields. For nested data, Superforms uses dotted names (address.city) and nested error objects ($errors.address?.city). Use the dotted name for name and derive ids from it, as in validating nested object fields.

Server-only rules. A uniqueness check belongs in the action after superValidate. Add the error with setError(form, "email", "An account with this email already exists.") and return fail(400, { form }); it renders exactly like schema errors.

Choosing the Validation Method and Timing

Superforms’ validationMethod decides when client-side validation runs, and the choice maps directly onto the timing guidance used throughout this site. onblur gives each field its first verdict when the user leaves it and then re-validates on input once an error is showing — the “reward early, punish late” model from best practices for inline validation timing, and the right default for most forms. auto behaves similarly but validates on input for fields that already have errors. oninput validates on every keystroke from the start, which suits only fields with live feedback such as password requirement checklists. submit-only defers everything to submission, appropriate for very short forms. Whatever the method, the server-side superValidate in the action runs on every submission, so client-side timing is purely an experience choice and never a security one.

Keeping Descriptions Stable

The example renders each error paragraph conditionally and adds aria-describedby only when an error exists, which avoids referencing a missing element. An equally valid approach — slightly more robust with some screen readers — always renders the paragraph and toggles its hidden attribute, keeping the reference constant. Pick one pattern for the whole design system and apply it to every field component, so testing and behaviour are consistent.

Adding Server-Only Errors and Form-Level Messages

Superforms separates field errors (setError) from form-level messages (message), which maps well onto accessible delivery. Field errors render beside their inputs and drive focus; form-level messages render once in a status or alert region. Use message with a status of 400 or higher for form-level failures that no single field owns — “The service is busy, try again in a minute” — and render them in an alert region rather than as a field error.

import { setError, message } from "sveltekit-superforms";

export const actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, zod(signupSchema));
    if (!form.valid) return fail(400, { form });
    if (await users.exists(form.data.email)) {
      return setError(form, "email", "An account with this email already exists.");   // returns fail(400)
    }
    if (!(await captchaOk(request))) {
      return message(form, "Please complete the security check.", { status: 400 });   // form-level
    }
    await users.create(form.data);
    redirect(303, "/welcome");
  },
};

These server errors flow into the same $errors store as client-side ones, so the rendering and focus behaviour above applies unchanged — the unification described in mapping server field errors to form inputs.

What Superforms handles and what stays with you Two columns listing the responsibilities Superforms takes over and the accessibility and UX responsibilities that remain with the application's markup. Superforms handles ✓ schema validation on server and client ✓ values kept after failure ✓ constraints from the schema ✓ tainted tracking, nested data You still handle • labels and aria-describedby • aria-invalid and error placement • focus target via errorSelector • message wording and form-level alerts
Superforms moves data, errors and constraints; perceivable, focusable, well-worded errors are still the markup's job.

Frequently Asked Questions

What does Superforms add to SvelteKit form actions?

It validates with a schema in load and in actions through superValidate, keeps values and errors in client stores that work with use:enhance, derives native constraints from the schema, and tracks nested data and unsaved changes.

How do I make Superforms errors accessible?

Render each field's first error in an element referenced by the input's aria-describedby, set aria-invalid when there is an error, and set errorSelector so focus moves to the first invalid field after a failed submit.

Can Superforms validate on the client with the same Zod schema?

Yes. Pass validators: zodClient(schema) to superForm. The same schema then runs on blur or input on the client and again in the action on the server.

How do I add a server-only error such as "email already registered"?

In the action, after superValidate succeeds, call setError(form, "email", message) and return it. It appears in the same errors store as schema errors.

← Back to Svelte Form Validation