How to Build a Contact Form in SvelteKit (2026)
The Complete SvelteKit Contact Form Guide
A contact form is usually the first form you build in any web project. In SvelteKit, the cleanest approach uses Superforms for form handling and Zod for validation. This tutorial walks through building one from scratch — or you can generate one instantly with SvelteForms.
What You’ll Build
A contact form with four fields: name, email, subject, and message. It validates on both client and server, shows inline errors, displays a success message after submission, and works without JavaScript (progressive enhancement).
Prerequisites
You need a SvelteKit project with Superforms and Zod installed:
pnpm add sveltekit-superforms zod This tutorial uses SvelteKit 2, Svelte 5, and Superforms v2.
Step 1: Define the Zod Schema
Create src/routes/contact/schema.ts. The schema is the single source of truth for your form’s shape and validation:
import { z } from 'zod';
export const contactSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name is too long'),
email: z.string()
.email('Please enter a valid email address'),
subject: z.string()
.min(3, 'Subject must be at least 3 characters')
.max(200, 'Subject is too long'),
message: z.string()
.min(10, 'Message must be at least 10 characters')
.max(2000, 'Message must be under 2000 characters')
});
export type ContactSchema = typeof contactSchema; Each field has specific validation rules with custom error messages. The ContactSchema type export gives you TypeScript inference everywhere you use the form data.
Step 2: Create the Server Action
Create src/routes/contact/+page.server.ts. This handles form submission with 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 });
}
// Send the email or save to database
// await sendEmail({
// to: '[email protected]',
// from: form.data.email,
// subject: form.data.subject,
// body: form.data.message,
// });
return message(form, 'Thank you! Your message has been sent.');
}
}; The load function creates an empty form with default values. The action validates the request body against the schema — if invalid, it returns a 400 with the errors. If valid, you process the data (send email, save to DB) and return a success message.
Step 3: Build the Form Component
Create src/routes/contact/+page.svelte. This is the UI with client-side validation:
import { superForm } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { contactSchema } from './schema';
import type { PageData } from './$types';
let { data } = $props();
const { form, errors, enhance, submitting, message: msg } = superForm(data.form, {
validators: zod(contactSchema)
}); The superForm function gives you reactive stores for form values ($form), field errors ($errors), submission state ($submitting), and server messages ($msg). The validators option enables client-side validation using the same Zod schema.
The Template
<form method="POST" use:enhance class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium">Name</label>
<input id="name" name="name" bind:value={$form.name}
class="mt-1 block w-full rounded-md border px-3 py-2" />
{#if $errors.name}
<p class="mt-1 text-sm text-red-600">{$errors.name}</p>
{/if}
</div>
<div>
<label for="email" class="block text-sm font-medium">Email</label>
<input id="email" name="email" type="email" bind:value={$form.email}
class="mt-1 block w-full rounded-md border px-3 py-2" />
{#if $errors.email}
<p class="mt-1 text-sm text-red-600">{$errors.email}</p>
{/if}
</div>
<div>
<label for="subject" class="block text-sm font-medium">Subject</label>
<input id="subject" name="subject" bind:value={$form.subject}
class="mt-1 block w-full rounded-md border px-3 py-2" />
{#if $errors.subject}
<p class="mt-1 text-sm text-red-600">{$errors.subject}</p>
{/if}
</div>
<div>
<label for="message" class="block text-sm font-medium">Message</label>
<textarea id="message" name="message" rows="5" bind:value={$form.message}
class="mt-1 block w-full rounded-md border px-3 py-2"></textarea>
{#if $errors.message}
<p class="mt-1 text-sm text-red-600">{$errors.message}</p>
{/if}
</div>
{#if $msg}
<p class="rounded-md bg-green-50 p-3 text-sm text-green-800">{$msg}</p>
{/if}
<button type="submit" disabled={$submitting}
class="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50">
{$submitting ? 'Sending...' : 'Send Message'}
</button>
</form> Key points: use:enhance makes the form submit via fetch when JavaScript is available, but it still works as a standard POST form without JS. Each field shows its error message reactively. The submit button disables during submission to prevent double-sends.
Step 4: Handle Email Sending
The server action has a placeholder for email sending. Here are common options:
- Resend —
pnpm add resend, thennew Resend('re_xxx').emails.send({...}) - SendGrid —
pnpm add @sendgrid/mail - Nodemailer — for SMTP-based sending
- Database only — save to your DB and check manually
The form data is already validated by the time it reaches your handler, so you can trust form.data without additional checks.
Skip the Boilerplate
This tutorial showed every file by hand. SvelteForms generates all three files — schema, server action, and component — from a visual builder. Load the Contact Form template, customize it, and export in seconds.
See more patterns in our guides on client + server validation and Zod schema patterns.
Build forms faster
Try the form builder →