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
useEmailVerificationhook with debouncing - A verification API endpoint (Next.js Route Handler or Express)
- An accessible
EmailInputcomponent 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
| Practice | Why |
|---|---|
| Debounce at 600ms | Wait for the user to finish typing before firing a request |
| Abort in-flight requests | Prevent race conditions when email changes quickly |
| Fail open on fetch errors | Network issues shouldn't block the form |
aria-invalid + aria-describedby | Accessible 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