Mailbeam
React + Custom HookIntermediate20 minutesUpdated January 2025

Email Verification in React

This tutorial builds a real-time email verification hook for React. As the user types their email, the hook debounces requests to your backend, which calls Mailbeam. The result drives inline UI states: loading spinner, error message, or green check.

What you'll build

  • A useEmailVerification hook with debouncing
  • A verification API endpoint (Next.js Route Handler or Express)
  • An accessible EmailInput component with inline status
  • Integration into a signup form

Prerequisites

  • React 18+ project
  • A backend API you can add a route to
  • A Mailbeam API key (sign up free)

Step 1 — Create the API route

The hook calls your backend, which calls Mailbeam. This keeps the API key server-side.

Next.js Route Handler:

// app/api/verify-email/route.ts
import { NextResponse } from "next/server";
import Mailbeam from "@mailbeam/sdk";

const mb = new Mailbeam({ apiKey: process.env.MAILBEAM_KEY! });

export async function POST(request: Request) {
  const { email } = await request.json();

  if (!email || typeof email !== "string") {
    return NextResponse.json({ error: "email is required" }, { status: 400 });
  }

  try {
    const result = await mb.verify(email);
    return NextResponse.json(result);
  } catch {
    // Fail open — return a "valid" response so the form doesn't block
    return NextResponse.json({ valid: true, score: 50, reason: null });
  }
}

Express equivalent:

// routes/verifyEmail.js
import Mailbeam from "@mailbeam/sdk";
const mb = new Mailbeam({ apiKey: process.env.MAILBEAM_KEY });

router.post("/api/verify-email", async (req, res) => {
  const { email } = req.body;
  try {
    const result = await mb.verify(email);
    res.json(result);
  } catch {
    res.json({ valid: true, score: 50, reason: null }); // fail open
  }
});

Step 2 — Build the hook

// hooks/useEmailVerification.ts
import { useState, useEffect, useRef } from "react";

export type VerificationStatus = "idle" | "loading" | "valid" | "invalid";

export interface VerificationResult {
  valid: boolean;
  score: number;
  reason: string | null;
}

export interface UseEmailVerificationReturn {
  status: VerificationStatus;
  result: VerificationResult | null;
  errorMessage: string | null;
}

const REASON_MESSAGES: Record<string, string> = {
  invalid_syntax: "Please check the email format.",
  no_mx_records: "This email domain doesn't seem to accept mail.",
  smtp_rejected: "This email address doesn't appear to exist.",
  disposable_domain: "Please use a permanent email, not a temporary one.",
  role_address: "Please use a personal email address.",
  catch_all_unverifiable: "We couldn't fully verify this address — please double-check it.",
};

export function useEmailVerification(
  email: string,
  { debounceMs = 600, minScore = 60 }: { debounceMs?: number; minScore?: number } = {}
): UseEmailVerificationReturn {
  const [status, setStatus] = useState<VerificationStatus>("idle");
  const [result, setResult] = useState<VerificationResult | null>(null);
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    // Reset if email is empty or looks incomplete
    if (!email || !email.includes("@") || !email.includes(".")) {
      setStatus("idle");
      setResult(null);
      return;
    }

    setStatus("loading");

    const timer = setTimeout(async () => {
      // Cancel any in-flight request
      abortRef.current?.abort();
      abortRef.current = new AbortController();

      try {
        const res = await fetch("/api/verify-email", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email }),
          signal: abortRef.current.signal,
        });

        if (!res.ok) throw new Error(`HTTP ${res.status}`);

        const data: VerificationResult = await res.json();
        setResult(data);
        setStatus(data.valid && data.score >= minScore ? "valid" : "invalid");
      } catch (err) {
        if ((err as Error).name === "AbortError") return;
        // Fail open — don't block the form on fetch errors
        setStatus("idle");
      }
    }, debounceMs);

    return () => {
      clearTimeout(timer);
      abortRef.current?.abort();
    };
  }, [email, debounceMs, minScore]);

  const errorMessage =
    status === "invalid" && result?.reason
      ? (REASON_MESSAGES[result.reason] ?? "Please provide a valid email address.")
      : null;

  return { status, result, errorMessage };
}

Step 3 — Create the EmailInput component

// components/EmailInput.tsx
import { useId } from "react";
import { type UseEmailVerificationReturn } from "@/hooks/useEmailVerification";

interface EmailInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  verification: UseEmailVerificationReturn;
  label?: string;
}

export function EmailInput({
  verification,
  label = "Email address",
  ...inputProps
}: EmailInputProps) {
  const id = useId();
  const errorId = `${id}-error`;
  const { status, errorMessage } = verification;

  return (
    <div className="space-y-1">
      <label
        htmlFor={id}
        className="block text-sm font-medium text-foreground"
      >
        {label}
      </label>

      <div className="relative">
        <input
          id={id}
          type="email"
          autoComplete="email"
          aria-invalid={status === "invalid"}
          aria-describedby={status === "invalid" ? errorId : undefined}
          className={`
            w-full rounded-lg border px-3 py-2 pr-9 text-sm bg-background
            focus:outline-none focus:ring-2 focus:ring-ring
            ${status === "invalid" ? "border-destructive" : ""}
            ${status === "valid" ? "border-green-500" : "border-border"}
          `}
          {...inputProps}
        />

        {/* Status indicator */}
        <span
          className="absolute right-3 top-1/2 -translate-y-1/2 text-sm"
          aria-hidden="true"
        >
          {status === "loading" && (
            <span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
          )}
          {status === "valid" && <span className="text-green-500">✓</span>}
          {status === "invalid" && <span className="text-destructive">✗</span>}
        </span>
      </div>

      {errorMessage && (
        <p id={errorId} role="alert" className="text-xs text-destructive">
          {errorMessage}
        </p>
      )}
    </div>
  );
}

Step 4 — Use in a signup form

// app/signup/page.tsx
"use client";

import { useState } from "react";
import { EmailInput } from "@/components/EmailInput";
import { useEmailVerification } from "@/hooks/useEmailVerification";

export default function SignupPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const verification = useEmailVerification(email);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    // Block submission while verification is loading or failed
    if (verification.status === "loading" || verification.status === "invalid") {
      return;
    }

    const res = await fetch("/api/auth/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });

    if (res.ok) {
      window.location.href = "/dashboard";
    }
  }

  const canSubmit =
    verification.status === "valid" || verification.status === "idle";

  return (
    <main className="flex min-h-screen items-center justify-center px-4">
      <form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
        <h1 className="text-2xl font-bold">Create account</h1>

        <EmailInput
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          verification={verification}
        />

        <div>
          <label htmlFor="password" className="block text-sm font-medium mb-1">
            Password
          </label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full rounded-lg border border-border px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
          />
        </div>

        <button
          type="submit"
          disabled={!canSubmit || !email || !password}
          className="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
        >
          Create account
        </button>
      </form>
    </main>
  );
}

Best practices

PracticeWhy
Debounce at 600msWait for the user to finish typing before firing a request
Abort in-flight requestsPrevent race conditions when email changes quickly
Fail open on fetch errorsNetwork issues shouldn't block the form
aria-invalid + aria-describedbyAccessible error state for screen readers
Gate submit on "valid" | "idle"Don't allow submitting mid-verification

Production checklist

  • API route is server-side only (API key not in client bundle)
  • Debounce tuned for your UX (600ms is a good starting point)
  • Error messages are actionable ("use a permanent email" vs "email invalid")
  • Form accessible: aria-invalid, aria-describedby, roles set
  • Fail-open tested: fetch error should still allow form submission

Next steps