Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 037 / 100

Forms with validation in SvelteKit in Svelte & SvelteKit

Great forms with server validation.

Expona Admin7 min read

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:

typescript
// 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.

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:

typescript
npm install sveltekit-superforms zod

Here's how you'd set up a form in a SvelteKit page component:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
<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:

typescript
<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:

typescript
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 $submitting state 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-describedby for accessibility.
  • Leverage the $submitting store 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles