Hand-written SvelteKit actions repeat the same plumbing on every form — parse FormData, coerce types, validate, collect errors by field, echo values, mirror constraints into the markup, repeat the rules on the client — and every copy drifts slightly from the others.

Superforms packages that plumbing around a single schema. The server calls superValidate(request, adapter(schema)); the page gets a superForm store that knows the values, errors, constraints, submitting state and which fields the user has changed. This page wires it with Zod, configures client validation to match good error timing, and covers the parts that still need decisions: nested data, dirty-state navigation guards and server-only rules. It builds on SvelteKit form actions with use:enhance inside Svelte store integration for forms.


Context and prerequisites

What Superforms gives you on each side:

  • Server: superValidate(request, zod(schema)) parses and coerces FormData using the schema’s types (numbers, booleans, dates), validates, and returns a form object with valid, data, errors and constraints. Return it with fail(400, { form }) on error; message(form, …) and setError(form, "field", …) add server messages.
  • Client: superForm(data.form, options) returns stores — $form (values), $errors, $constraints, $submitting, $tainted — and an enhance action that wraps SvelteKit’s.
  • Constraints: derived from the schema (required, minlength, pattern, min, max) and spread onto inputs, so the browser’s native validity and required state match the schema.

The schema is the contract; everything else is derived from it.

One schema, four consumers The Zod schema is defined once. superValidate uses it on the server to parse and validate the posted FormData. Superforms derives HTML constraints such as required and minlength from it and spreads them onto inputs. The client superForm store can run the same schema for instant feedback. TypeScript types for the form data are inferred from it for both server and page. Zod schema Defined once. Server superValidate parses and validates FormData. Markup $constraints: required, minlength, pattern. Client Same schema for instant feedback; inferred types.

The core pattern: superValidate on the server, superForm on the page

// src/lib/schemas/profile.ts
import { z } from "zod";
export const profileSchema = z.object({
  name: z.string().trim().min(2, "Use at least 2 characters."),
  email: z.string().trim().email("Enter an email like [email protected]."),
  age: z.number().int().min(16, "You must be 16 or over.").optional(),
  newsletter: z.boolean().default(false),
});
// src/routes/profile/+page.server.ts
import { fail } from "@sveltejs/kit";
import { superValidate, message, setError } from "sveltekit-superforms";
import { zod } from "sveltekit-superforms/adapters";
import { profileSchema } from "$lib/schemas/profile";

export const load = async ({ locals }) => {
  // Populate from the current record; superValidate with data does not show errors on load.
  const form = await superValidate(await getProfile(locals.user.id), zod(profileSchema));
  return { form };
};

export const actions = {
  default: async ({ request, locals }) => {
    const form = await superValidate(request, zod(profileSchema));
    if (!form.valid) return fail(400, { form });
    // Server-only rule: the schema cannot know whether the email is taken.
    if (await emailTakenByOther(form.data.email, locals.user.id)) {
      return setError(form, "email", "That email is used by another account.");
    }
    await saveProfile(locals.user.id, form.data);
    return message(form, "Profile saved.");
  },
};
declare function getProfile(id: string): Promise<Record<string, unknown>>;
declare function emailTakenByOther(e: string, id: string): Promise<boolean>;
declare function saveProfile(id: string, d: unknown): Promise<void>;
<!-- src/routes/profile/+page.svelte -->
<script lang="ts">
  import { superForm } from "sveltekit-superforms";
  import { zodClient } from "sveltekit-superforms/adapters";
  import { profileSchema } from "$lib/schemas/profile";
  let { data } = $props();
  const { form, errors, constraints, message, submitting, enhance } = superForm(data.form, {
    validators: zodClient(profileSchema),  // client-side validation with the same schema
    validationMethod: "onblur",             // first errors on blur…
    taintedMessage: "You have unsaved changes. Leave anyway?",
    resetForm: false,                       // edit form: keep values after save
  });
</script>

<form method="POST" use:enhance novalidate>
  <label for="name">Name</label>
  <input id="name" name="name" bind:value={$form.name} {...$constraints.name}
    aria-invalid={$errors.name ? "true" : undefined} aria-describedby={$errors.name ? "name-error" : undefined} />
  {#if $errors.name}<p id="name-error">{$errors.name[0]}</p>{/if}
  <!-- email and age follow the same pattern -->
  <button aria-disabled={$submitting}>{$submitting ? "Saving…" : "Save"}</button>
  {#if $message}<p role="status">{$message}</p>{/if}
</form>

Step-by-step walkthrough

  1. Put the schema in $lib. Server and page import the same module; nothing is copied.
  2. Load with superValidate(data, adapter). Passing existing data populates the form without reporting errors on first render.
  3. Validate in the action with superValidate(request, adapter). It coerces FormData strings into the schema’s types, so age arrives as a number and newsletter as a boolean.
  4. Add server-only rules with setError. Uniqueness, permissions and anything needing the database stay on the server and land on the right field — the same idea as mapping 422 responses to field errors.
  5. Configure client validation timing. validators enables it; validationMethod: "onblur" shows first errors on blur, while fields with errors re-validate as they change — matching reward early, punish late.
  6. Spread $constraints onto inputs. The browser gets required, minlength and friends, and assistive technology announces required fields.
  7. Use taintedMessage for the unsaved-changes guard. Superforms tracks which fields the user changed and prompts on navigation, as in warning before leaving a form with unsaved changes.

What the library decides for you, and what it does not

Superforms makes firm decisions about data flow: the server is authoritative, the client store mirrors the returned form object, and errors are arrays of strings per path. Those decisions are sound and save a great deal of code. It does not decide your error presentation — whether there is a summary, where focus goes after a failed submit, how messages are worded, or how errors are announced. Those remain yours, and the accessibility behaviour of a Superforms form is only as good as the markup you render from its stores.

Superforms stores and what to bind them to The form store holds values and is bound to inputs with bind:value. The errors store holds arrays of messages per field and drives the error paragraphs, aria-invalid and aria-describedby. The constraints store holds HTML validation attributes derived from the schema and is spread onto inputs. The tainted store records user-changed fields and drives the unsaved-changes prompt. The submitting store drives the pending label on the button. The message store holds a form-level success or failure message shown in a status or alert region. Store Holds Bind to $form values bind:value on inputs $errors string[] per path error text, aria-invalid, aria-describedby $constraints required, minlength… spread onto inputs $tainted changed fields unsaved-changes prompt $submitting boolean button label, aria-disabled $message form-level message role=status or role=alert

Failure modes and edge cases

1. Nested objects and arrays without dataType: "json"

HTML forms post flat key/value pairs. For nested objects or arrays, set dataType: "json" in superForm options; Superforms then posts the data as JSON instead of relying on field names. This requires JavaScript — for no-JS support, keep the schema flat.

2. Optional numbers from empty inputs

An empty number input posts "". Superforms treats it according to the schema’s optionality and defaults; check that an empty optional age becomes undefined rather than 0 in your version and schema, and use .optional() or .nullable() explicitly.

3. Errors shown on load

Calling superValidate(request, …) in load (instead of with data) validates an empty form and shows errors immediately. Load with data, or with nothing, and validate only in actions.

4. Focus after failure

Superforms can scroll to the first error (scrollToError) and focus it (autoFocusOnError). Check both are on, and if you render an error summary, focus the summary instead so users hear how many problems there are.

5. Client and server messages disagree

Client validation uses the same schema, but server-only rules exist only on the server. Word them consistently and put them on fields, so the user sees the same style of message whichever side produced it.

A save with a server-only rule failing The user blurs through fields and client validation shows nothing because values are well formed. On submit, superValidate on the server also passes. The server-only check finds the email is used by another account and calls setError on the email field. The action returns a 400 with the form object, and the client store shows the message under the email field and focuses it. Client validation passes Same schema, on blur. Values are well formed. superValidate passes Coerced, validated on the server. The schema agrees with the client. Server-only rule fails email used by another account setError(form, "email", …) returns 400. Error on the field $errors.email updated. Shown under the input; focus moves to it.

Verification checklist


Frequently Asked Questions

Do I need Superforms if I already use form actions?

No. Actions plus a schema work well for small forms. Superforms pays off when you have many forms, want client validation from the same schema, need typed coercion of FormData, or want built-in tainted tracking and constraints.

Can I use Valibot or another library instead of Zod?

Yes. Superforms ships adapters for several validation libraries; replace zod/zodClient with the matching adapter. The rest of the page is unchanged, which is the benefit of keeping validation behind an adapter.

Where should async checks like "username available" run?

As server-only rules in the action for the authoritative check, and optionally as a debounced client request for early feedback, following implementing async email availability checks. Avoid async refinements in the shared schema, which would run the network check during every client validation.


Related

← Svelte Store Integration for Forms