emailverifier.dev
Posts

Validate Signup Emails With an Astro Action

| 4 min read | Usama Ejaz
Astro Action email validation cover with a form submitting to one server function.

An Astro Action can own the whole server-side signup check: Zod validates the submitted form, the action makes one email-verification request, and the page reads a typed success or error result. The API key never enters the browser bundle.

This example uses Astro’s server output because account creation and secret-backed verification require a runtime server.

Create the server-rendered Astro project

npm create astro@latest astro-email-signup
cd astro-email-signup
npx astro add node
npm install --save-dev vitest

The Node adapter updates astro.config.mjs. Confirm it contains:

import { defineConfig } from 'astro/config'
import node from '@astrojs/node'

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' })
})

Astro Actions validate form inputs and return either data or an ActionError. The current Actions guide documents defineAction, Zod input schemas, form actions, and Astro.getActionResult().

Keep the decision in a testable function

Create src/lib/signup-policy.ts:

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

export function signupDecision(report: VerificationResult) {
  if (report.action === 'block') {
    return {
      decision: 'block' as const,
      message: report.suggestion
        ? 'Check the spelling of your email address.'
        : 'Use a different email address.'
    }
  }

  if (report.action === 'review') {
    return {
      decision: 'review' as const,
      message: 'Confirm this address before receiving full access.'
    }
  }

  return {
    decision: 'allow' as const,
    message: 'Continue to account creation.'
  }
}

Define the Action and its one API request

Create src/actions/index.ts:

import { ActionError, defineAction } from 'astro:actions'
import { z } from 'astro/zod'

import {
  signupDecision,
  type VerificationResult
} from '../lib/signup-policy'

export const server = {
  signup: defineAction({
    accept: 'form',
    input: z.object({
      email: z.string().trim().toLowerCase().email().max(254)
    }),
    handler: async ({ email }) => {
      const apiKey = import.meta.env.EMAILVERIFIER_API_KEY
      if (!apiKey) {
        throw new ActionError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Email verification is not configured.'
        })
      }

      let response: Response
      try {
        response = await fetch(
          'https://emailverifier.dev/api/v1/verify',
          {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'X-API-Key': apiKey
            },
            body: JSON.stringify({ email }),
            signal: AbortSignal.timeout(4000)
          }
        )
      } catch {
        throw new ActionError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Email verification is temporarily unavailable.'
        })
      }

      if (!response.ok) {
        throw new ActionError({
          code: 'INTERNAL_SERVER_ERROR',
          message: 'Email verification is temporarily unavailable.'
        })
      }

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

      if (result.decision === 'block') {
        throw new ActionError({
          code: 'BAD_REQUEST',
          message: result.message
        })
      }

      if (result.decision === 'allow') {
        // Create the account here, after the verification decision.
      }

      return {
        ...result,
        signals: report.signals
      }
    }
  })
}

The single fetch() is the action’s only outbound request. A blocked address becomes a visible BAD_REQUEST. A network, key, credit, or upstream failure remains an internal service error.

Render the form and Action result

Create src/pages/signup.astro:

---
import { actions, isInputError } from 'astro:actions'

const result = Astro.getActionResult(actions.signup)
const fieldErrors = result?.error && isInputError(result.error)
  ? result.error.fields
  : {}
---

<main>
  <h1>Create your account</h1>

  {result?.error && !isInputError(result.error) && (
    <p role="alert">{result.error.message}</p>
  )}

  {result?.data && (
    <p role="status">{result.data.message}</p>
  )}

  <form method="POST" action={actions.signup}>
    <label for="email">Email</label>
    <input
      id="email"
      name="email"
      type="email"
      autocomplete="email"
      required
      aria-describedby="email-error"
    />
    <p id="email-error" role="alert">
      {fieldErrors.email?.join(' ')}
    </p>
    <button type="submit">Continue</button>
  </form>
</main>

Browser validation improves the form interaction, but the Action’s Zod schema remains the trust boundary. A direct POST cannot bypass it.

Test the policy without using credits

Create src/lib/signup-policy.test.ts:

import { describe, expect, it } from 'vitest'
import { signupDecision } from './signup-policy'

describe('signupDecision', () => {
  it('blocks a disposable result', () => {
    const result = signupDecision({
      email: 'person@example.com',
      status: 'risky',
      action: 'block',
      flagged: true,
      signals: ['disposable_address'],
      suggestion: null
    })

    expect(result.decision).toBe('block')
  })

  it('preserves an inconclusive result for confirmation', () => {
    const result = signupDecision({
      email: 'person@example.com',
      status: 'unknown',
      action: 'review',
      flagged: false,
      signals: ['verification_inconclusive'],
      suggestion: null
    })

    expect(result.decision).toBe('review')
  })
})

Add "test": "vitest run" to the generated package scripts, then run npm test.

Start Astro and submit the form

Create a project and copy its API key. Add an uncommitted .env file:

EMAILVERIFIER_API_KEY=your-project-key

Only variables prefixed with PUBLIC_ are intended for client exposure in Astro. This key has no such prefix and is referenced only by a server Action.

npm run dev

Open http://localhost:4321/signup. A blocked address returns a form error, an uncertain address returns a confirmation message, and an allowed address reaches the marked account-creation boundary.

The possible verification statuses are deliverable, risky, undeliverable, and unknown. The possible actions are allow, review, and block. Keep those fields separate when you replace the marked boundary with your database write. The verification and confirmation guide explains why an allowed or reviewable address may still need an ownership step.

Continue reading