emailverifier.dev
Posts

SvelteKit Email Verification With a Form Action

| 4 min read | Usama Ejaz
SvelteKit form action cover with an email form document and a check mark.

This tutorial builds a working SvelteKit signup-email form with a server action. It starts from a new project, works without browser JavaScript, keeps the API key private, and returns field-level feedback for allow, review, and block outcomes.

Create the SvelteKit project

npx sv create svelte-email-check
cd svelte-email-check
npm install

Choose the minimal template and TypeScript when prompted. You will edit:

svelte-email-check/
├─ src/routes/+page.server.ts
├─ src/routes/+page.svelte
└─ .env

The current SvelteKit project guide uses npx sv create.

Add the private environment value

Create an emailverifier.dev project and copy the API key. Add it to .env:

EMAILVERIFIER_API_KEY=replace_with_your_project_key

The server action imports it from $env/static/private. SvelteKit prevents that module from being imported into client code.

Implement the form action

Create src/routes/+page.server.ts:

import { EMAILVERIFIER_API_KEY } from '$env/static/private'
import { fail } from '@sveltejs/kit'
import type { Actions } from './$types'

type VerificationResult = {
  email: string
  status: 'deliverable' | 'risky' | 'undeliverable' | 'unknown'
  action: 'allow' | 'block' | 'review'
  flagged: boolean
  signals: string[]
  suggestion: string | null
}

export const actions = {
  default: async ({ request, fetch }) => {
    const form = await request.formData()
    const emailValue = form.get('email')
    const email = typeof emailValue === 'string'
      ? emailValue.trim()
      : ''

    if (!email) {
      return fail(400, {
        email,
        error: 'Email is required.'
      })
    }

    let response: Response
    try {
      response = await fetch(
        'https://emailverifier.dev/api/v1/verify',
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-API-Key': EMAILVERIFIER_API_KEY
          },
          body: JSON.stringify({ email }),
          signal: AbortSignal.timeout(5000)
        }
      )
    } catch {
      return fail(503, {
        email,
        error:
          'Email verification is temporarily unavailable.'
      })
    }

    if (!response.ok) {
      return fail(503, {
        email,
        error:
          'Email verification is temporarily unavailable.'
      })
    }

    const result =
      (await response.json()) as VerificationResult

    if (result.action === 'block') {
      return fail(422, {
        email,
        error: result.signals.includes('disposable_address')
          ? 'Use a permanent email address.'
          : 'Check the email address and try again.',
        suggestion: result.suggestion
      })
    }

    return {
      email: result.email,
      accepted: true,
      needsReview: result.action === 'review',
      result
    }
  }
} satisfies Actions

A returned fail value sends the status and form data back to the page without throwing away the submitted email. The SvelteKit form-actions guide documents this behavior.

Render the page

Replace src/routes/+page.svelte with:

<script lang="ts">
  import type { PageProps } from './$types'

  let { form }: PageProps = $props()
</script>

<svelte:head>
  <title>Signup email check</title>
</svelte:head>

<main>
  <h1>Check a signup email</h1>

  <form method="POST">
    <label for="email">Email</label>
    <input
      id="email"
      name="email"
      type="email"
      autocomplete="email"
      required
      value={form?.email ?? ''}
      aria-describedby={form?.error
        ? 'email-error'
        : undefined}
    />

    {#if form?.error}
      <p id="email-error" role="alert">
        {form.error}
      </p>
    {/if}

    {#if form?.suggestion}
      <p>Did you mean {form.suggestion}?</p>
    {/if}

    {#if form?.accepted}
      <p aria-live="polite">
        {form.needsReview
          ? 'Continue with email confirmation.'
          : 'Email accepted.'}
      </p>
      <p>Status: {form.result.status}</p>
    {/if}

    <button type="submit">Check email</button>
  </form>
</main>

The form uses normal HTML POST behavior. JavaScript can later enhance it with use:enhance, but the integration does not require that dependency or an extra client request.

Run the tutorial

npm run dev

Open http://localhost:5173 and submit an address. The page should preserve the email after a failed result, show a suggestion when present, and display the returned status for an accepted result.

Disable JavaScript in the browser and submit again. The form should still call the server action and render the response after navigation. That is the observable benefit of using a SvelteKit form action instead of a browser-only fetch handler.

Test the result branches

The REST API reference defines the four statuses:

  • deliverable: allow by default, while preserving review signals for role or catch-all addresses.
  • risky: block disposable addresses or show a likely correction.
  • undeliverable: block a confirmed syntax, routing, or recipient failure.
  • unknown: continue through confirmation or review because the recipient evidence was inconclusive.

For automated tests, stub the action's external fetch with one fixture for each status plus timeout and non-200 failures. Assert the returned form status and data rather than a live provider classification.

Connect it to account creation

The action currently returns an accepted decision. Replace that return with the existing user-creation call, or pass the result to the service that owns registration. Keep the mutation after the block branch so a rejected address cannot receive a user row or trial credit.

Keep the form's review path separate from inbox ownership by following Email Validation vs Verification vs Confirmation.

The tutorial is complete when the form works with JavaScript disabled, a blocked response renders beside the field, an unknown response follows the confirmation path, and the API key is absent from browser source.

Continue reading