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.
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.
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.
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.
Related Guides
- Svelte Form Validation — the hand-written alternative.
- Svelte 5 Runes Form Validation — validation with runes only.
- SvelteKit Form Actions Validation — the underlying form actions.
- Using Zod for Complex Form Schemas — schema design for Superforms.