SvelteKit Form Validation: Client + Server the Right Way
Why You Need Both Client and Server Validation
Every SvelteKit form needs two layers of validation. Client-side gives instant feedback as users type. Server-side ensures data integrity regardless of what the client sends. Skipping either one creates real problems.
Client-only validation is trivially bypassed. Anyone can open DevTools, disable JavaScript, or send a raw HTTP request with curl. If your server trusts client validation alone, you're accepting unvalidated data into your database.
Server-only validation works but creates a terrible UX. Users submit the form, wait for a round-trip, then see errors. On slow connections this feels broken. Users abandon forms that don't give immediate feedback.
The Superforms + Zod Solution
Superforms solves this by using a single Zod schema for both sides. You define validation once, and it runs on the client for instant feedback and on the server before processing. Zero duplication, zero drift between client and server rules.
Step 1: Define the Schema
Create a schema.ts file in your route directory. This is the single source of truth for your form's shape and validation rules:
import { z } from 'zod';
export const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email address'),
message: z.string()
.min(10, 'Message must be at least 10 characters')
.max(500, 'Message must be under 500 characters'),
subscribe: z.boolean().default(false)
});
export type ContactSchema = typeof contactSchema;
Notice the schema exports both the runtime validator and the TypeScript type. You get type-safe form data everywhere you use it.
Step 2: Server-Side Action
The +page.server.ts file handles form submission with full server-side validation:
import { superValidate, message } from 'sveltekit-superforms/server';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { contactSchema } from './schema';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const form = await superValidate(zod(contactSchema));
return { form };
};
export const actions: Actions = {
default: async ({ request }) => {
const form = await superValidate(request, zod(contactSchema));
if (!form.valid) {
return fail(400, { form });
}
// Process the validated data
await saveToDatabase(form.data);
await sendNotificationEmail(form.data.email, form.data.message);
return message(form, 'Message sent successfully!');
}
};
Key details: superValidate in the load function creates an empty form with default values. In the action, it parses the request body against the schema. If validation fails, fail(400, { form }) sends the errors back to the client with the form state preserved — users don't lose what they typed.
Step 3: Client Component with Validation
The +page.svelte component wires up client-side validation using the same schema:
<script lang="ts">
import { superForm } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { contactSchema } from './schema';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { form, errors, enhance, submitting } = superForm(data.form, {
validators: zod(contactSchema)
});
</script>
<form method="POST" use:enhance>
<label for="name">Name</label>
<input id="name" name="name" bind:value={$form.name} />
{#if $errors.name}<span class="error">{$errors.name}</span>{/if}
<label for="email">Email</label>
<input id="email" name="email" type="email" bind:value={$form.email} />
{#if $errors.email}<span class="error">{$errors.email}</span>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" bind:value={$form.message}></textarea>
{#if $errors.message}<span class="error">{$errors.message}</span>{/if}
<button type="submit" disabled={$submitting}>
{$submitting ? 'Sending...' : 'Send Message'}
</button>
</form>
The validators: zod(contactSchema) option enables client-side validation. Errors appear instantly as users type, without waiting for a server round-trip. The use:enhance directive progressively enhances the form — it works without JavaScript (standard form POST), but when JS is available, it submits via fetch and updates errors reactively.
Progressive Enhancement: Forms That Work Without JavaScript
One of Superforms' most underappreciated features is that your forms work with JavaScript disabled. The use:enhance directive is progressive — without it, the form submits normally as a POST request. The server action still validates and returns errors via SvelteKit's standard form action response.
This matters for accessibility, for users on flaky connections where JS fails to load, and for search engine crawlers that don't execute JavaScript. Your form is functional from the first HTML byte.
Common Validation Patterns
Conditional Required Fields
Use Zod's .refine() for fields that are required based on other field values:
const schema = z.object({
wantNewsletter: z.boolean(),
email: z.string().optional()
}).refine(
data => !data.wantNewsletter || (data.email && data.email.includes('@')),
{ message: 'Email required for newsletter', path: ['email'] }
);
Custom Error Messages Per Rule
Each Zod validator accepts a custom message string. Use specific, helpful messages instead of generic "Invalid" errors:
z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Include at least one uppercase letter')
.regex(/[0-9]/, 'Include at least one number')
Skip the Boilerplate
Writing Zod schemas, server actions, and form components by hand is repetitive. SvelteForms generates all three files from a visual drag-and-drop builder — the same patterns shown above, ready to commit into your project. Try the form builder or start from a template.
Build forms faster
Try the form builder →