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 coercesFormDatausing the schema’s types (numbers, booleans, dates), validates, and returns aformobject withvalid,data,errorsandconstraints. Return it withfail(400, { form })on error;message(form, …)andsetError(form, "field", …)add server messages. - Client:
superForm(data.form, options)returns stores —$form(values),$errors,$constraints,$submitting,$tainted— and anenhanceaction 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.
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
- Put the schema in
$lib. Server and page import the same module; nothing is copied. - Load with
superValidate(data, adapter). Passing existing data populates the form without reporting errors on first render. - Validate in the action with
superValidate(request, adapter). It coercesFormDatastrings into the schema’s types, soagearrives as a number andnewsletteras a boolean. - 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. - Configure client validation timing.
validatorsenables it;validationMethod: "onblur"shows first errors on blur, while fields with errors re-validate as they change — matching reward early, punish late. - Spread
$constraintsonto inputs. The browser getsrequired,minlengthand friends, and assistive technology announces required fields. - Use
taintedMessagefor 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.
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.
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.