Forms with validation in SvelteKit in Svelte & SvelteKit
Building robust forms with proper validation is one of the most common requirements in web applications, yet many developers still struggle to get it right. When you combine server-side validation with a seamless user experience, you unlock a level of reliability and user confidence that's hard to achieve otherwise. In this guide, we'll explore how to build forms with validation in SvelteKit that feel polished on the client while maintaining security and correctness on the server.
Schema — Zod schemas shared by client and server
The foundation of any well-validated form is a single source of truth for your data structure. Rather than duplicating validation logic between client and server, we use Zod to define schemas that work everywhere.
Imagine you're building a user registration form. Instead of writing validation in JavaScript on the front end and then again in your backend logic, define your schema once:
// src/lib/schemas.ts
import { z } from 'zod';
export const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2, 'Name must be at least 2 characters'),
terms: z.boolean().refine(val => val === true, {
message: 'You must accept the terms'
})
});
export type RegisterForm = z.infer<typeof registerSchema>;This schema lives in your src/lib directory, making it importable from both your client components and your server actions. The z.infer pattern gives you TypeScript types automatically derived from the schema, eliminating another source of duplication.
On the client side, you can validate as the user types or on blur events. On the server, you validate before processing the form submission. Because the schema is identical in both places, you're confident that data reaching your database matches your expectations.
Superforms — A popular library for SvelteKit forms
While you could manually wire up form validation, Superforms is a library that handles the scaffolding and synchronization between client and server automatically. It's become the de facto standard in the SvelteKit ecosystem for good reason.
Install it alongside your existing dependencies:
npm install sveltekit-superforms zodHere's how you'd set up a form in a SvelteKit page component:
// src/routes/register/+page.svelte
<script lang="ts">
import { superForm } from 'sveltekit-superforms/client';
import { registerSchema } from '$lib/schemas';
import type { PageData } from './$types';
export let data: PageData;
const { form, errors, enhance, submitting } = superForm(data.form, {
validators: registerSchema
});
</script>
<form method="POST" use:enhance>
<div>
<label for="email">Email</label>
<input
id="email"
type="email"
name="email"
bind:value={$form.email}
aria-invalid={$errors.email ? 'true' : undefined}
/>
{#if $errors.email}
<span class="error">{$errors.email}</span>
{/if}
</div>
<div>
<label for="password">Password</label>
<input
id="password"
type="password"
name="password"
bind:value={$form.password}
aria-invalid={$errors.password ? 'true' : undefined}
/>
{#if $errors.password}
<span class="error">{$errors.password}</span>
{/if}
</div>
<button type="submit" disabled={$submitting}>
{$submitting ? 'Creating account...' : 'Register'}
</button>
</form>On the server side, your form action receives the data and validates it:
// src/routes/register/+page.server.ts
import { superValidate } from 'sveltekit-superforms/server';
import { registerSchema } from '$lib/schemas';
import { fail, type Actions } from '@sveltejs/kit';
export const actions: Actions = {
default: async ({ request }) => {
const form = await superValidate(request, registerSchema);
if (!form.valid) {
return fail(400, { form });
}
// Process the validated data
try {
const user = await db.user.create({
email: form.data.email,
password: form.data.password,
name: form.data.name
});
// Handle success...
} catch (e) {
// Handle errors like duplicate email
form.errors.email = ['Email already registered'];
return fail(400, { form });
}
}
};
export const load = async () => {
const form = await superValidate(registerSchema);
return { form };
};Superforms handles the plumbing: it serializes form data, sends it to your action, validates it, and returns errors back to the client where they're automatically bound to your form state. If validation passes, you can redirect or show success feedback without writing extra code.
Errors — Show field errors returned by actions
Displaying validation errors clearly to users is critical. Superforms makes this straightforward by attaching errors to the form state automatically.
When a validation error occurs on the server, the action returns the form with errors populated. The client reactively displays them:
// Each field can have an array of errors
{#if $errors.email}
<div class="field-errors">
{#each $errors.email as error}
<p class="error-message">{error}</p>
{/each}
</div>
{/if}You can also show form-level errors separate from field-level ones:
const { form, errors, message } = superForm(data.form, {
validators: registerSchema
});
// In your component
{#if $message}
<div class="alert alert-{$message.type}">
{$message.text}
</div>
{/if}In your server action, set this after processing:
return {
form,
message: {
type: 'error',
text: 'Failed to create account. Please try again.'
}
};For better accessibility, use the aria-invalid attribute on invalid fields and connect error messages with aria-describedby so screen readers announce them properly.
Loading states — Disable buttons while submitting
While a form is being submitted, we need to prevent double submissions and give users feedback. Superforms provides the $submitting store which is true while the request is in flight.
Use it to disable the submit button and optionally show a loading indicator:
<button type="submit" disabled={$submitting}>
{#if $submitting}
<span class="spinner"></span> Creating account...
{:else}
Register
{/if}
</button>You can also disable all form inputs during submission to prevent users from changing data mid-request:
<input
id="email"
type="email"
name="email"
bind:value={$form.email}
disabled={$submitting}
aria-invalid={$errors.email ? 'true' : undefined}
/>If you're dealing with network-heavy operations, consider adding a timeout or retry mechanism. Superforms integrates well with additional error handling:
const { form, errors, enhance, submitting } = superForm(data.form, {
validators: registerSchema,
onError: ({ result }) => {
if (result.status === 408 || result.status === 504) {
// Network timeout, show retry option
}
}
});Common mistakes
We've seen teams run into predictable pitfalls when building forms:
- Forgetting server validation: Client validation is for UX, not security. Always validate on the server before writing data.
- Duplicating schemas: Writing validation twice invites inconsistencies. Use Zod everywhere.
- Not handling race conditions: If you allow multiple submissions, you might process duplicate requests. Use the
$submittingstate to prevent this. - Poor error messaging: Generic errors like "Invalid input" frustrate users. Be specific about what's wrong.
- Ignoring accessibility: Screen reader users need proper labels, error associations, and ARIA attributes.
Key takeaways
- Define your validation schema once with Zod and import it in both client and server code, eliminating duplication and ensuring consistency.
- Use Superforms to handle the synchronization between form state on the client and validation results from the server automatically.
- Display field-level errors clearly and associate them with inputs using
aria-describedbyfor accessibility. - Leverage the
$submittingstore to disable buttons and inputs while requests are in flight, preventing double submissions. - Always validate on the server before processing data, regardless of client-side validation.
- Test your forms with real network conditions and error scenarios to ensure they degrade gracefully.






Comments
Loading comments…