Validate email in a React form: client-side UX, server-side truth
Client-side syntax feedback is enough for UX; MX, disposable and role checks belong on your server, where the bs_live_ key can't leak into the browser.
Contents
You have a signup form in React, someone types me@gmial.con, and you want to catch it before it becomes a dead row in your users table. Searching for how to validate email in React turns up a hundred regexes, all of them arguing with each other. Here is the boundary that actually matters: the browser can tell you instantly whether an address is shaped like an email, but whether mail can reach it is a question you can only answer from your server — never from the browser.
Start with what the browser already gives you
Before any library, <input type="email"> plus the Constraint Validation API covers well-formed-address checking for free:
<input type="email" required />
Call checkValidity() on the element and the browser runs its own email grammar for you — no regex, no dependency:
const input = document.querySelector("input[type=email]");
input.checkValidity(); // false for "me@", true for "me@example.com"
For form UX, this is often all you need, and you should admit that to yourself before installing anything. It's synchronous, it's built in, and it gives the user an instant red border. What it does not do is tell you the domain exists or can receive mail — like every client-side check, it validates syntax only. (If you've ever fought a "perfect" RFC 5322 regex, we wrote about why that pattern is a myth.)
A controlled input with a debounced check
Here's a controlled field that gives instant syntax feedback and defers the expensive check to onBlur, debounced so a jumpy focus doesn't spam your backend:
import { useState, useRef, useCallback } from "react";
function useDebounced(fn, delay) {
const timer = useRef();
return useCallback((...args) => {
clearTimeout(timer.current);
timer.current = setTimeout(() => fn(...args), delay);
}, [fn, delay]);
}
export function EmailField() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState({ state: "idle" });
const ref = useRef(null);
const verify = useDebounced(async () => {
if (!email || !ref.current.checkValidity()) {
setStatus({ state: "syntax" }); // browser already objects; skip the network
return;
}
setStatus({ state: "checking" });
const res = await fetch("/api/validate-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
setStatus({ state: "done", data: await res.json() });
}, 400);
return (
<label>
Email
<input
ref={ref}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={verify}
required
/>
{status.state === "checking" && <small>Checking the domain…</small>}
{status.state === "done" && !status.data.mx_found && (
<small>That domain can't receive mail.</small>
)}
</label>
);
}
Notice the fetch goes to /api/validate-email — your own route, same origin. That's not an accident.
Why the deep checks can't run in the browser
The layer past syntax — does the domain publish an MX record, is it a disposable throwaway, is it a role address like info@ — needs a call to Boundstone. That call is authenticated with a secret:
Authorization: Bearer bs_live_YOUR_KEY
Anything in your React bundle is public. View-source, the Network tab, a bookmarklet — a bs_live_ key shipped to the browser is a key you've handed to everyone. So the rule is flat: the key lives on your server, the browser talks to your server, and your server talks to Boundstone. There is no client-side exception to this one.
The server route
Your route holds the key and returns only what the form needs. This is a Web-standard handler (Next.js route handler, Cloudflare, Deno — same shape):
// POST /api/validate-email — runs on your server; the key never leaves it
export async function POST(req) {
const { email } = await req.json();
const r = await fetch("https://api.boundstone.io/v1/verify/email", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BOUNDSTONE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const data = await r.json();
return Response.json({
mx_found: data.mx_found,
disposable: data.disposable,
role_account: data.role_account,
checks: data.checks,
});
}
For a valid address the response carries both halves of the honesty contract:
{
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
}
Read both arrays. performed tells you Boundstone confirmed the syntax, resolved a live MX record, and screened the domain against its disposable and role lists. not_performed is the part most APIs quietly omit: Boundstone does not ping the mailbox over SMTP, and does not try to detect catch-all domains. Those are hard to do without hurting deliverability, so it declines to fake them. A valid you can trust is one that tells you what it didn't check. (The response also includes free_provider — a flag noting gmail.com and friends — but that's a lookup, not a verification, so it isn't in checks.performed.)
The Node side of this route, with retries and error handling, is its own post: validating email in Node.js.
The short version
- Client-side (
type="email"+checkValidity()): instant, free, syntax only. Enough for UX. Do this first. - Server-side (your route → Boundstone): MX, disposable, role. The only place the
bs_live_key may exist. - Never put the key in the React bundle. Same-origin
fetchto your own backend, always. - Trust the
checks.not_performedarray — it's the honest edge of the result, not a footnote to bury.
Want to see the shape before you wire anything up? The keyless email validator tool runs the same checks in your browser, no signup and no key. When you're ready, the free tier is 250 checks a month, no card, and the credits don't expire.