# Validate email in a React form: client-side UX, server-side truth

_2026-10-23 · Boundstone (https://boundstone.io/blog/validate-email-react)_


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:

```jsx
<input type="email" required />
```

Call `checkValidity()` on the element and the browser runs its own email grammar for you — no regex, no dependency:

```javascript
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](/blog/email-regex-rfc5322-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:

```jsx
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:

```http
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):

```javascript
// 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:

```json
{
  "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](/blog/validate-email-nodejs).

## 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](/tools/email-validator) 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.
