A React signup form usually treats email validation as a boolean: the field is either valid or it shows a red error. That model throws away the useful part of a modern validator response. nobounce returns four verdicts, an optional suggestion, and a checked.mx flag that tells you whether DNS finished. The UI has to map those into states a user can act on — especially “Did you mean …?” — without turning a timeout into a blocked registration.
This article covers the browser side. The API key stays on the server. The React form talks only to your own route.
Keep the key off the client
There is no free tier. Live DNS checks need a paid key — hobby is $1/mo for 1,000 checks, pro is $19/mo for 100,000, scale is $99/mo for 1,500,000 — obtained by redeeming an operator-minted access code or paying self-serve on the Stripe card rail or the usevig stablecoin rail.
That key belongs in a server environment variable and in an Authorization header on the outbound call. Do not put it in NEXT_PUBLIC_*, VITE_*, or any bundle shipped to the browser. The browser posts the typed address to /api/validate-email; that route calls https://nobounce.dev/v1/check. The server-side shape of that call is covered in How to Validate an Email Address in Node.js.
Shape the form state around actions, not booleans
import { useState } from "react";
const INITIAL = {
email: "",
status: "idle", // idle | checking | suggest | reject | ready | unchecked
suggestion: null,
reason: null,
mxChecked: null,
};
export function SignupEmailField({ onReady }) {
const [state, setState] = useState(INITIAL);
async function validate(email) {
setState((s) => ({ ...s, status: "checking", suggestion: null, reason: null }));
let payload;
try {
const response = await fetch("/api/validate-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(2500),
});
if (!response.ok) {
setState((s) => ({ ...s, status: "unchecked", mxChecked: false }));
onReady?.({ email, verdict: "unchecked", mxChecked: false });
return;
}
payload = await response.json();
} catch {
setState((s) => ({ ...s, status: "unchecked", mxChecked: false }));
onReady?.({ email, verdict: "unchecked", mxChecked: false });
return;
}
if (payload.action === "suggest") {
setState((s) => ({
...s,
status: "suggest",
suggestion: payload.suggestion,
reason: payload.reason,
}));
return;
}
if (payload.action === "reject") {
setState((s) => ({ ...s, status: "reject", reason: payload.reason }));
return;
}
setState((s) => ({
...s,
status: "ready",
reason: payload.reason ?? null,
mxChecked: payload.mxChecked ?? true,
}));
onReady?.({
email,
verdict: payload.verdict ?? "deliverable",
mxChecked: payload.mxChecked ?? true,
});
}
// ...render below
}
Your API route should return the small action object from the Node helper (suggest, reject, accept, or a null-turned-unchecked), not the raw nobounce payload. That keeps React ignorant of transport details and makes the fail-open path explicit.
Render four outcomes, not one error string
| form status | what the user sees | can submit? |
|---|---|---|
checking |
spinner or muted “Checking…” under the field | no |
suggest |
“Did you mean user@gmail.com?” with Accept / Keep typed |
only after a choice |
reject |
reason-specific message, no correction available | no |
ready |
quiet success affordance, or nothing | yes |
unchecked |
nothing blocking; optionally a soft note | yes |
The suggestion branch is the product. Accepting it writes the suggested address into the controlled input and re-marks the field ready. Keeping the typed value also marks ready, but records that the user declined the correction — useful later if the message bounces. Do not silently rewrite the input: confidence is not certainty. The correction mechanics behind suggestion are covered in Suggest the Correction Instead of Rejecting the Signup.
{state.status === "suggest" && state.suggestion && (
<div role="status">
<p>Did you mean <strong>{state.suggestion}</strong>?</p>
<button
type="button"
onClick={() => {
setState((s) => ({
...s,
email: state.suggestion,
status: "ready",
suggestion: null,
}));
onReady?.({
email: state.suggestion,
verdict: "corrected",
mxChecked: true,
});
}}
>
Use suggestion
</button>
<button
type="button"
onClick={() => {
setState((s) => ({ ...s, status: "ready", suggestion: null }));
onReady?.({
email: state.email,
verdict: "kept_typed",
mxChecked: true,
});
}}
>
Keep what I typed
</button>
</div>
)}
{state.status === "reject" && (
<p role="alert">That address cannot receive mail ({state.reason}).</p>
)}
risky should land in ready. Disposable domains and role accounts such as admin@ can receive mail; blocking them is a product rule you apply deliberately, not a validity fact the form should hard-code. unknown with checked.mx: false is also ready/unchecked territory: the DNS lookup did not finish, so the honest UI is to let the user through and store that the check was degraded.
Call on blur or submit, never on every keystroke
Wire validation to onBlur of the email input, or to the form’s onSubmit before you create the account. Live DNS on each keystroke burns quota on incomplete strings (d, di, die…) and races the controlled input against in-flight responses.
If you debounce blur for fast tabbing, cancel the previous AbortController when the value changes. Treat abort the same as timeout: set unchecked and allow submit. That is the same advisory rule described in Fail Open: Email Validation Must Never Block a Signup — the validator may annotate the record; it may not be the reason registration fails when the network is the thing that broke.
What the server must return to the form
Your /api/validate-email route should:
- Read the email from the JSON body.
- Call
POST https://nobounce.dev/v1/checkwithAuthorization: Bearer $NOBOUNCE_KEYand a ~2s timeout. - Map the verdict into
{ action, suggestion?, reason?, verdict?, mxChecked? }. - On timeout, 5xx, or RFC 9457 problem details, return
{ action: "accept", verdict: "unchecked", mxChecked: false }with HTTP 200 — not a 502 that the form then misreads as “reject”.
nobounce does not store a plaintext address; anything persisted server-side there is SHA-256, with domains in clear. Your own logs should follow the same rule: log reason and checked.mx, not the raw email.
POST /demo/check is useful while you are wiring the UI because it needs no key, but it is fixture-only. It resolves the frozen corpus from GET https://nobounce.dev/v1/config and cannot validate arbitrary live addresses. Point the React form at your authenticated server route before you ship.
Verify the four branches before merge
Drive the form with known inputs (or stub the API route) and confirm:
user@gmai.com→ suggestion UI, not a hard error.not-an-email→ reject withsyntax_invalid.- a normal deliverable address → submit enabled, no banner.
- an aborted or timed-out request → submit still enabled,
mxChecked: falserecorded.
A happy-path screenshot proves the API works. The timeout case proves the signup survives when it does not.
For the ordered key-acquisition, verdict-mapping, and verification procedure, give your coding agent /integrate.md.