emailverifier.dev
Posts

React Hook Form Email Validation With a Server-Side Check

| 4 min read | Usama Ejaz
Simple signup form connected to a server for a React Hook Form email validation tutorial.

React Hook Form should handle immediate field feedback. The server should handle email verification. Putting both jobs inside an async browser validator would expose the project key and make account creation depend on client code that can be bypassed.

This example keeps the split explicit:

  • register() handles required and basic format errors.
  • handleSubmit() sends one request to your own server.
  • The Express route makes one emailverifier.dev request and returns a field-safe error.

Scaffold the React application

npm create vite@latest react-email-check -- --template react
cd react-email-check
npm install
npm install react-hook-form express

Add a server script to the existing scripts object in package.json:

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "server": "node --env-file=.env server.mjs"
  }
}

Create an emailverifier.dev account and project, copy the project key, and create .env:

EMAILVERIFIER_API_KEY=ev_your_project_key
API_PORT=3001

Vite exposes client variables prefixed with VITE_. Do not give the project key that prefix. The server reads it directly from process.env.

Proxy API requests during development

Replace vite.config.js:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': 'http://localhost:3001'
    }
  }
})

The browser calls /api/signup. Vite forwards that path to Express locally, so the component does not need a separate development URL or CORS configuration.

Build the React Hook Form component

Replace src/App.jsx:

import { useState } from 'react'
import { useForm } from 'react-hook-form'

export default function App() {
  const [result, setResult] = useState(null)
  const {
    register,
    handleSubmit,
    setError,
    clearErrors,
    formState: { errors, isSubmitting }
  } = useForm({ defaultValues: { email: '' } })

  async function onSubmit(values) {
    clearErrors('root')
    setResult(null)

    try {
      const response = await fetch('/api/signup', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(values)
      })
      const body = await response.json()

      if (!response.ok) {
        if (response.status === 422) {
          setError('email', { type: 'server', message: body.error })
        } else {
          setError('root.server', { message: body.error || 'Signup is unavailable.' })
        }
        return
      }

      setResult(body)
    } catch {
      setError('root.server', { message: 'Could not reach the signup service.' })
    }
  }

  return (
    <main>
      <h1>Create account</h1>
      <form onSubmit={handleSubmit(onSubmit)} noValidate>
        <label htmlFor="email">Email address</label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          aria-invalid={errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
          {...register('email', {
            required: 'Enter an email address.',
            maxLength: { value: 320, message: 'The email address is too long.' },
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: 'Check the email format.'
            }
          })}
        />
        {errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}
        {errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? 'Checking…' : 'Continue'}
        </button>
      </form>

      {result && (
        <section aria-live="polite">
          <h2>Signup decision</h2>
          <p>Next step: {result.next}</p>
          <pre>{JSON.stringify(result.verification, null, 2)}</pre>
        </section>
      )}
    </main>
  )
}

The regular expression is deliberately limited to obvious browser feedback. It is not an RFC implementation and does not make a deliverability claim.

Add the server-side verification route

Create server.mjs in the project root:

import express from 'express'

const app = express()
const port = Number(process.env.API_PORT || 3001)
const apiKey = process.env.EMAILVERIFIER_API_KEY

if (!apiKey) throw new Error('EMAILVERIFIER_API_KEY is required')

app.use(express.json({ limit: '10kb' }))

app.post('/api/signup', async (request, response) => {
  const email = typeof request.body.email === 'string' ? request.body.email.trim() : ''

  if (!email || email.length > 320) {
    response.status(400).json({ error: 'Enter an email address.' })
    return
  }

  try {
    const verifierResponse = 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(5000)
    })

    if (!verifierResponse.ok) {
      throw new Error(`Verifier returned HTTP ${verifierResponse.status}`)
    }

    const verification = await verifierResponse.json()

    if (verification.action === 'block') {
      response.status(422).json({
        error: verification.suggestion
          ? `Did you mean ${verification.suggestion}?`
          : verification.signals.includes('disposable_address')
            ? 'Use a long-term email address.'
            : 'Check the email address and try again.',
        verification
      })
      return
    }

    response.json({
      accepted: true,
      next: verification.action === 'review' ? 'confirm_email' : 'create_account',
      verification
    })
  } catch (error) {
    console.error(error)
    response.status(503).json({ error: 'Signup checks are temporarily unavailable.' })
  }
})

app.listen(port, () => {
  console.log(`API: http://localhost:${port}`)
})

Run the two processes in separate terminals:

npm run server
npm run dev

Open the Vite URL. Obvious format errors appear before submission. A server block attaches to the same email field. An outage appears as a form-level error and does not masquerade as a bad address.

The four statuses are not form errors

StatusMeaningReact form behavior
deliverablePositive mailbox evidence was available.Continue to account creation unless action says review.
riskyA risk signal such as disposable provider or typo was found.Show a field error only when action is block.
undeliverableA confirmed failure was found.Attach the server message to the email field.
unknownThe mailbox evidence was inconclusive.Continue to confirmation instead of showing “invalid.”

React Hook Form’s register options are a good fit for synchronous field rules, while handleSubmit owns the async submission. The validation, verification, and confirmation guide explains why those states should remain separate in your user model.

Continue reading