Validate Signup Emails With an Astro Action
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.