← Blog

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 fetch to your own backend, always.
  • Trust the checks.not_performed array — 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.

Frequently asked questions

How should I validate an email address in a React form?

Use two layers. In the browser, run a lightweight format check (an HTML5 type=email input or a simple regex) to catch typos and empty fields and give instant feedback — this is purely a UX aid and can be bypassed. On the server, call a validation API that confirms the syntax is well-formed, that the domain publishes an MX record able to accept mail, and that the address isn't a disposable or role account before you actually create the account. The client layer is for feel; the server layer is the truth you act on.

Does a 'valid' email result mean the address is real and can receive mail?

No. A valid result from syntax plus MX checking means the address is correctly structured and the domain advertises mail servers — it does not prove the specific mailbox exists or that your message will land. Boundstone is explicit about this line: it reports syntax, MX, disposable-list and role-account checks as performed, and reports SMTP mailbox probing and catch-all detection as not performed, so you never mistake well-formed for deliverable. Treat valid as worth sending to, not a delivery guarantee.

Can I rely on client-side email validation alone in React?

No. Client-side validation only improves the form experience and is trivially bypassed — a user can disable JavaScript or edit the request before it ever reaches your backend, so it is never a security or data-quality control. Always re-validate on the server, where you can check the domain's MX record, disposable domains, role addresses and whether it's a free provider that a browser regex can't see. A good pattern is a fast regex in the form for immediate feedback, then a server-side call that returns exactly which checks ran so you can decide to block, flag, or allow the signup with full knowledge of what was and wasn't verified.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist