How to Verify Email Addresses on Signup in Next.js (App Router)
To verify email addresses on signup in Next.js, do the real check in a Server Action: validate the syntax with validator.isEmail, look up MX records with node:dns, and return field errors through useActionState. Return the submitted value so React 19's automatic form reset doesn't wipe it. Run anything slow in after() instead of making the user wait.
Everything here was built and run on Next.js 16.4.0 and React 19.3.0 on 6 October 2026, and the form was driven in headless Chrome to confirm the behaviour described. The test results are near the end.
What runs where
The browser gives instant hints, the Server Action makes the decision, and anything slow runs after the response.
| Step | Where | What it catches | Blocks signup? |
|---|---|---|---|
type="email" |
Browser | Missing @, spaces | No, it's a hint (bypassable) |
validator.isEmail |
Server Action | Malformed addresses, double dots, over-long local parts | Yes |
| MX lookup | Server Action | Dead domains, typos like gmial.con, null MX |
Yes, when DNS answers clearly |
| Mailbox check | after() or a queue |
Non-existent mailboxes | No, it flags the account |
| Confirmation email | After signup | Everything else, including someone typing another person's address | Gates access |
Why the server matters: the Next.js docs warn that you should "always verify authentication and authorization inside each Server Action". The same reasoning applies to validation. A Server Action is an HTTP endpoint, and a script can POST to it without ever loading your form.
Step 1: a server-only check function
Keep the check in its own module, mark it server-only, and return a status, not a boolean.
npm install validator server-only
npm install -D @types/validator
// app/lib/check-email.ts
import "server-only";
import { Resolver } from "node:dns/promises";
import { domainToASCII } from "node:url";
import validator from "validator";
export type EmailCheck =
| { status: "invalid"; reason: string }
| { status: "no-mail"; reason: string }
| { status: "ok"; email: string }
| { status: "unknown"; email: string; reason: string };
const resolver = new Resolver({ timeout: 2000, tries: 1 });
export async function checkEmail(raw: string): Promise<EmailCheck> {
const trimmed = raw.trim();
if (!validator.isEmail(trimmed)) {
return { status: "invalid", reason: "That doesn't look like an email address." };
}
const at = trimmed.lastIndexOf("@");
const domain = domainToASCII(trimmed.slice(at + 1).toLowerCase());
if (!domain) return { status: "invalid", reason: "The part after @ isn't a valid domain." };
const email = `${trimmed.slice(0, at)}@${domain}`;
try {
const mx = await resolver.resolveMx(domain);
if (mx.length === 1 && mx[0].exchange === "") {
return { status: "no-mail", reason: `${domain} does not accept email.` };
}
return { status: "ok", email };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOTFOUND") {
return { status: "no-mail", reason: `${domain} doesn't exist. Check for a typo.` };
}
if (code === "ENODATA") {
const hasAddress = await resolver.resolve4(domain).then(
(a) => a.length > 0,
() => false,
);
return hasAddress
? { status: "ok", email }
: { status: "no-mail", reason: `${domain} can't receive email.` };
}
return { status: "unknown", email, reason: code ?? "dns-error" };
}
}
Three details are deliberate:
- Null MX. A domain that publishes
0 .under RFC 7505 is saying it never accepts mail. Node returns that as one record with an emptyexchange. - Implicit MX. With no MX record, RFC 5321 section 5.1 says to fall back to the domain's address record, so
ENODATAplus an A record is still a pass. unknownis notinvalid. A DNS timeout says nothing about the user. The"server-only"import makes the build fail if a Client Component ever imports this file.
The layers are explained in more depth in validating email in JavaScript and Node.js. This post is about wiring them into the App Router.
Step 2: the Server Action
With useActionState, the action receives the previous state first and the FormData second, and whatever it returns becomes the new state.
// app/signup/actions.ts
"use server";
import { after } from "next/server";
import { checkEmail } from "@/app/lib/check-email";
export type SignupState = {
email: string;
error?: string;
done?: boolean;
};
export async function signup(_prev: SignupState, formData: FormData): Promise<SignupState> {
const raw = String(formData.get("email") ?? "");
if (raw.length > 254) return { email: "", error: "That address is too long." };
const result = await checkEmail(raw);
if (result.status === "invalid" || result.status === "no-mail") {
return { email: raw, error: result.reason };
}
const verified = result.status === "ok";
await createUser(result.email, { emailDomainVerified: verified });
if (!verified) {
after(() => recheckLater(result.email));
}
return { email: "", done: true };
}
createUser and recheckLater are your own functions. The important part is the branch: a clear DNS answer blocks the signup, and an unclear one creates the account with a flag and schedules a re-check. after has been stable since Next.js 15.1.0 and runs once the response is finished. The docs say it runs "for the platform's default or configured max duration of your route", so give it short jobs or hand the work to a queue.
The 254 limit comes from RFC 5321's 256-octet path limit, which includes the angle brackets around the address. Checking it before anything else keeps a huge string away from the parser and the resolver.
Step 3: the form, and the reset that wipes your input
React 19 resets uncontrolled fields after a form action completes, even when the action returns an error, so pass the submitted value back as defaultValue.
The React <form> reference says: "After the action function succeeds, all uncontrolled field elements in the form are reset." Returning { error } is a success as far as React is concerned. We confirmed it: with the defaultValue line removed, submitting jane@gmial.con showed the error message and left an empty field. With it in place, the typo stays so the user can fix it.
// app/signup/signup-form.tsx
"use client";
import { useActionState } from "react";
import { signup, type SignupState } from "./actions";
const initialState: SignupState = { email: "" };
export function SignupForm() {
const [state, formAction, pending] = useActionState(signup, initialState);
if (state.done) return <p role="status">Check your inbox to confirm your address.</p>;
return (
<form action={formAction} noValidate>
<label htmlFor="email">Work email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={state.email}
aria-invalid={state.error ? true : undefined}
aria-describedby={state.error ? "email-error" : undefined}
/>
{state.error && (
<p id="email-error" role="alert">
{state.error}
</p>
)}
<button type="submit" disabled={pending}>
{pending ? "Checking…" : "Create account"}
</button>
</form>
);
}
noValidate turns off the browser's own error bubbles so every message comes from one place, the server. Remove it if you want the native hint for a missing @ before the round trip. pending is the third value useActionState returns, so you don't need a separate useFormStatus component for the button.
For the client-side half (blur timing, typo suggestions, accessible error text), see validating email in a React form.
Adding a verification API call
If you add a mailbox check from a vendor, call it from the server with a hard timeout, and treat a timeout as unknown.
Inside checkEmail, after the MX lookup passes:
const res = await fetch(`${process.env.VERIFY_API_URL}?email=${encodeURIComponent(email)}`, {
headers: { Authorization: `Bearer ${process.env.VERIFY_API_KEY}` },
signal: AbortSignal.timeout(2500),
}).catch(() => null);
Use an environment variable without the NEXT_PUBLIC_ prefix so the key never ships to the browser. If res is null or the vendor says "unknown", return unknown and let the user in. Vendors differ on auth, timeout units and status names, which is covered in email verification APIs compared. Signup bursts also hit per-second limits, so read handling email verification API rate limits before launch. The full latency argument is in real-time email verification on signup forms.
What the test run returned
Four submissions through the real form in headless Chrome:
| Input | Result shown | Field after submit |
|---|---|---|
jane@gmial.con |
"gmial.con doesn't exist. Check for a typo." | Kept |
jane@example.com |
"example.com does not accept email." (null MX) | Kept |
not an email |
"That doesn't look like an email address." | Kept |
jane@gmail.com (leading space) |
Success, user created as jane@gmail.com |
n/a |
Protecting the action itself
An action that runs a DNS lookup on every request is an amplifier, so rate-limit it and screen out bots before the lookup.
Put a honeypot field and a per-IP limit in front of checkEmail. Honeypot fields cost real users nothing, and the wider set of layers is in how to stop fake signups. An MX check doesn't catch throwaway domains with working mail servers. For those, see blocking disposable emails.
The short version
- Check syntax and MX in the Server Action. The browser check is only a convenience.
- Return the submitted email in state and use it as
defaultValue, or React will clear the field. - Block only on clear answers: bad syntax,
ENOTFOUND, null MX. Letunknownthrough and flag it. - Push slow checks into
after()or a queue, and send a confirmation email before the account gets anything of value. - To see what a full mailbox-level check returns for one address, try the free email verifier.
Common questions
Where should email verification run in a Next.js App Router app?
In a Server Action or Route Handler, never only in the browser. Use input type=email for instant feedback, then check syntax and MX records on the server inside the action that creates the account. Server Actions are reachable by direct POST requests, so the server check is the one that counts.
Why does my email field go blank after a server action returns an error?
React 19 resets uncontrolled form fields after a form action completes without throwing, and returning an error object counts as completing. Return the submitted value in your action state and pass it to the input's defaultValue, and the field keeps what the user typed.
Should I call an email verification API inside the Server Action?
Only with a hard timeout and a fail-open rule. Put the key in a server-only environment variable, abort the request after two or three seconds, and create the account as unconfirmed if the API is slow or returns unknown. Slow third-party checks can also run in after(), once the response has been sent.
Is an MX lookup enough to verify an email address?
No. An MX lookup proves the domain can receive mail. It cannot tell you whether the mailbox exists. That needs an SMTP check or a confirmation email that the user clicks.
Check your list before you send
Real mailbox checks over SMTP, and nothing is sent to the address. Sign in with Google and your first 100 email checks are free, no card. Plans for bigger lists are on the pricing page.