Building Email Validation into SvelteKit Form Actions

hangrydev ·

Client-Side Validation Doesn’t Protect Your List

You’ve got a Zod schema on your signup form. The email field passes z.string().email(). The form looks validated. It’s not.

z.string().email() runs a regex. It’ll reject user@ and catch a missing dot in the domain. It won’t catch [email protected], [email protected], or [email protected] pointed at a catch-all server that accepts everything. Those addresses pass Zod, enter your database, and bounce the first time you try to email them.

Worse: with client-only validation, your API key would need to live in the browser. Anyone can open DevTools and read it. That’s a separate problem from validation accuracy, but it’s the reason the check has to happen server-side anyway.

SvelteKit’s form actions solve both. The action runs on the server, the API key stays in $env/static/private, and the response comes back to the page with fail(). Add use:enhance and users never see a full page reload. That’s the full stack we’re building here.

What SvelteKit Form Actions Give You

A form action is a function exported from +page.server.ts that SvelteKit calls when a form submits. No separate API route. No custom fetch handler. The form’s action attribute points to the current page (or a named action like ?/signup), and SvelteKit routes the POST there.

// src/routes/signup/+page.server.ts
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const email = data.get('email');
    // validate, call APIs, return result
  }
};

Two things SvelteKit handles automatically: parsing the form data and wiring the result back into the page’s form prop. You return a plain object and the page can read it immediately. No serialization, no state management boilerplate.

The Zod Schema

Define your schema once and import it on both the server action and optionally on the client for instant feedback.

// src/lib/schemas/signup.ts
import { z } from 'zod';

export const signupSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z
    .string()
    .min(1, 'Email is required')
    .email("That doesn't look like a valid email"),
});

export type SignupInput = z.infer<typeof signupSchema>;

This schema is pure format checking. It’s the first filter, not the last. What it catches: missing @, bare strings, and obvious garbage. What it misses: dead domains, disposable addresses, catch-all servers, and typo’d TLDs like gmial.com.

The email validation API guide has a full breakdown of what each validation layer catches and what it doesn’t.

The Form Action with MailCop

Here’s the full server action. It validates format with Zod, then fires a real-time check against MailCop’s API for deliverability, then creates the user.

// src/routes/signup/+page.server.ts
import { fail } from '@sveltejs/kit';
import { TRUEMAIL_API_KEY } from '$env/static/private';
import { signupSchema } from '$lib/schemas/signup';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();

    const raw = {
      name: data.get('name'),
      email: data.get('email'),
    };

    // Step 1: Zod format validation
    const parsed = signupSchema.safeParse(raw);
    if (!parsed.success) {
      return fail(400, {
        errors: parsed.error.flatten().fieldErrors,
        values: raw,
      });
    }

    // Step 2: Deliverability check via MailCop
    let emailStatus: string | null = null;

    try {
      const response = await fetch('https://api.mailcop.net/v1/verify', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${TRUEMAIL_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email: parsed.data.email }),
        signal: AbortSignal.timeout(3000),
      });

      if (response.ok) {
        const result = await response.json();
        emailStatus = result.status;

        if (result.status === 'undeliverable') {
          return fail(422, {
            errors: { email: ["That email address doesn't appear to be deliverable"] },
            values: raw,
          });
        }

        if (result.disposable) {
          return fail(422, {
            errors: { email: ["Disposable email addresses aren't allowed"] },
            values: raw,
          });
        }
      }
    } catch (err) {
      // API timeout or network error: fail open
      // Don't block a real user because the validation service is slow
      console.error('MailCop validation error:', err);
    }

    // Step 3: Create the user
    // await db.user.create({ data: parsed.data });

    return { success: true, email: parsed.data.email };
  },
};

Three decisions in here worth explaining.

$env/static/private is SvelteKit’s module for server-only environment variables. It can only be imported into server modules. SvelteKit will throw a build error if you accidentally import it into a .svelte file or any client-side module. The API key never reaches the browser.

fail() wraps the error response with an HTTP status code. Status 400 for validation errors (Zod schema failures), 422 for semantic errors (email exists but isn’t deliverable). The page receives this via the form prop automatically.

The catch block is intentional. If MailCop’s API times out or returns a network error, the action proceeds and creates the user anyway. An external validation service should never become a single point of failure for your signup flow. Validate in a background job if the real-time check fails.

The Page Component

The .svelte file reads the form prop and renders errors. Keep the structure simple: a <form> with method="POST", input fields that repopulate from form.values on failure, and error messages tied to form.errors.

<!-- src/routes/signup/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';

  let { form } = $props();
  let submitting = $state(false);
</script>

<form
  method="POST"
  use:enhance={() => {
    submitting = true;
    return async ({ update }) => {
      await update();
      submitting = false;
    };
  }}
>
  <div>
    <label for="name">Name</label>
    <input
      id="name"
      name="name"
      type="text"
      value={form?.values?.name ?? ''}
    />
    {#if form?.errors?.name}
      <p class="error" role="alert">{form.errors.name[0]}</p>
    {/if}
  </div>

  <div>
    <label for="email">Email</label>
    <input
      id="email"
      name="email"
      type="email"
      value={form?.values?.email ?? ''}
    />
    {#if form?.errors?.email}
      <p class="error" role="alert">{form.errors.email[0]}</p>
    {/if}
  </div>

  <button type="submit" disabled={submitting}>
    {submitting ? 'Checking...' : 'Sign up'}
  </button>

  {#if form?.success}
    <p>Account created. Check your inbox.</p>
  {/if}
</form>

This uses Svelte 5 runes. $props() replaces export let form. $state(false) declares submitting as reactive state, replacing a plain let variable. If you’re on Svelte 4, swap those for export let form and standard reactive variables.

How use:enhance Works

Without use:enhance, submitting the form triggers a full page navigation. The browser sends a POST, the server returns HTML, and the page reloads. That works. Forms have done this for 30 years.

use:enhance intercepts the submission, fires it as a fetch request, and updates the page without a full reload. The form prop updates in place. No layout flicker, no scroll position reset. Users get SPA-quality UX with none of the client-state complexity.

The callback pattern gives you control over the in-flight state:

use:enhance={() => {
  submitting = true;               // runs before the fetch
  return async ({ update }) => {
    await update();                // applies server response to form prop
    submitting = false;            // runs after the response
  };
}}

update() calls SvelteKit’s default behavior: apply the action result, invalidate page data, re-run load() functions. You can pass { reset: false } to keep form field values intact after a successful submission, or skip calling update() entirely for fully custom behavior.

No JavaScript? The form falls back to native browser behavior. Full page reload, the form prop populates from the server response, errors display exactly the same. That’s progressive enhancement. It actually works without JS.

Why Format Checks Alone Miss So Much

Run a quick mental test. Which of these pass z.string().email()?

All four. Every one of them. The Zod check sees valid syntax and moves on.

That’s not a knock on Zod. Format validation is fast, free, and runs without any network calls. It’s the right first layer. The problem is treating it as the only layer.

SMTP-level checks catch mailboxes that don’t exist even on a real domain. The MX vs SMTP validation accuracy post has data on what each layer adds. Short version: MX checks catch dead domains in milliseconds. SMTP checks catch dead mailboxes on live domains, but take 500ms to 3 seconds per address. For a signup form, MX checks in real time and SMTP async is the right trade-off.

For disposable addresses specifically, blocklists are the reliable approach. MailCop tracks over 180,000 disposable domains. Static regex patterns can’t keep up with new providers spinning up weekly. The disposable email blocklist post covers how these lists are maintained.

Named Actions for Multi-Form Pages

If your page has more than one form, use named actions.

// src/routes/account/+page.server.ts
export const actions: Actions = {
  updateEmail: async ({ request }) => {
    // handle email update
  },
  deleteAccount: async ({ request }) => {
    // handle deletion
  },
};

The form action attribute points to the named action:

<form method="POST" action="?/updateEmail" use:enhance>
  <!-- fields -->
</form>

Each action is isolated. fail() and return values flow back to the same form prop on the page, but SvelteKit tracks which action was last called so you can conditionally render the right error messages.

Handling Validation in Superforms

If you’re already using the sveltekit-superforms library, the integration is a thin wrapper around the same pattern. Superforms handles the Zod validation, return types, and client-side error binding for you.

// src/routes/signup/+page.server.ts (superforms version)
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail } from '@sveltejs/kit';
import { TRUEMAIL_API_KEY } from '$env/static/private';
import { signupSchema } from '$lib/schemas/signup';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  return { form: await superValidate(zod(signupSchema)) };
};

export const actions: Actions = {
  default: async ({ request }) => {
    const form = await superValidate(request, zod(signupSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    // MailCop check
    try {
      const response = await fetch('https://api.mailcop.net/v1/verify', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${TRUEMAIL_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email: form.data.email }),
        signal: AbortSignal.timeout(3000),
      });

      if (response.ok) {
        const result = await response.json();

        if (result.status === 'undeliverable') {
          form.errors.email = ["That email address doesn't appear to be deliverable"];
          return fail(422, { form });
        }
      }
    } catch {
      // fail open on timeout
    }

    return { form };
  },
};

Superforms generates the load function boilerplate, typed form state, and integrates with use:enhance automatically. Worth the dependency if you have complex multi-step forms. Overkill for a single signup form.

Webhook Patterns for Batch Validation

Real-time checks work for single signups. They don’t work for CSV imports. Running 5,000 synchronous API calls from a form action will time out.

For batch validation, submit the list to the API with a webhook URL and receive results asynchronously. SvelteKit API routes handle the incoming webhook.

// src/routes/api/webhooks/email-validation/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ request }) => {
  const payload = await request.json();

  for (const result of payload.results) {
    // Update contact record with validation status
    // await db.contact.update({ where: { email: result.email }, data: { status: result.status } });
  }

  return json({ received: true });
};

The email validation webhook patterns post walks through the full async flow, including signature verification and retry handling.

The Two Things That Actually Protect Your List

Zod catches format errors. That’s it. Everything else that matters happens server-side.

First: the API call runs under your key, on the server, where clients can’t touch it. That’s not optional. Putting a validation API key in client-side code means anyone can call your API on your bill.

Second: fail-open behavior on timeouts keeps your signup flow up even when the validation service has a bad moment. Accept the submission, flag the email as unverified, and validate asynchronously. Check out the Next.js email validation post for how the same pattern plays out in that framework.

The combination of format validation at the client, deliverability checks in the form action, and async SMTP verification in the background catches about 95% of bad addresses before they cause problems. Not perfect. Close enough to matter.