JavaScript email validation has two jobs: give people instant feedback while they type, and keep addresses that bounce out of your database. The browser and a regex handle the first job. Only your server can do the second, by checking that the domain receives mail and that the mailbox exists. This guide covers each layer with code we ran: Chrome 156 for the browser checks, Node.js 26 for everything else, and a local mock of the API for the mailbox check.
1. Let the browser check the format
<input type="email"> is the cheapest check there is. Mobile users get a keyboard with an @ key, and the browser blocks the form when the value doesn’t look like an address:
<input type="email" id="email" name="email" required autocomplete="email" maxlength="254">
The Constraint Validation API lets you replace the browser’s message with your own:
const input = document.querySelector("#email");
input.addEventListener("input", () => {
input.setCustomValidity(""); // reset first, or the field stays invalid
if (input.validity.typeMismatch) {
input.setCustomValidity("Please enter an email address like name@example.com.");
}
});
The browser applies the rule from the HTML standard, which is loose on purpose. In our test (Chrome 156, values set by script), jane@example without a top-level domain, jane..doe@example.com and a local part of 65 characters all passed, and input.value came back with surrounding spaces and line breaks already removed. The HTML email validator guide covers the rule, the pattern attribute and the differences between browsers.
2. JavaScript email validation with a regex
A regex closes the gaps the browser leaves, and you can run the same code in the browser and in Node
// email.js: runs in the browser and in Node.js
const EMAIL_REGEX =
/^(?=.{1,254}$)(?=.{1,64}@)[A-Za-z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+\/=?^_`{|}~-]+)*@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+(?:[A-Za-z]{2,63}|xn--[A-Za-z0-9-]{2,59})$/;
export function isValidEmail(value) {
return typeof value === "string" && EMAIL_REGEX.test(value);
}
The two lookaheads enforce the length limits (254 characters in total, 64 before the @). The rest allows plus tags and apostrophes, and rejects dots at the start or end of the local part, two dots in a row, hyphens at the edges of a domain label and domains without a top-level domain. The rules behind each part are in the guide to the email address format.
Two details matter in JavaScript. Without the m flag, ^ and $ match only at the start and end of the whole string, so "jane@example.com\n" fails. And never add the g flag: a global regex remembers where the last match ended, and calling test() twice on the same address returns true, then false.
Trim and convert internationalized domains
The pattern accepts internationalized domains only in their punycode form (xn--…). On the server, trim the input and convert the domain with domainToASCII from Node’s url module before you validate it:
// normalize.js: Node.js
import { domainToASCII } from "node:url";
// Trims the input and converts an internationalized domain to punycode.
export function normalizeEmail(input) {
const email = String(input).trim();
const at = email.lastIndexOf("@");
const domain = email.slice(at + 1);
// Convert only plain domain names; leave anything else for isValidEmail to reject.
if (at < 1 || !/^[\p{L}\p{M}\p{N}.-]+$/u.test(domain)) return email;
return email.slice(0, at + 1) + domainToASCII(domain);
}
normalizeEmail(" jane@bücher returns jane@xn--bcher-kva.example, which passes. The character check before the conversion matters: domain returns example.com, so without it jane@example.com/x would turn into a valid address.
3. validator.js and Zod
If your project already uses a validation library, it has an email check:
import isEmail from "validator/lib/isEmail.js"; // npm install validator
import { z } from "zod"; // npm install zod
isEmail("jane@example.com"); // true
const Email = z.email(); // Zod 4; Zod 3 writes z.string().email()
Email.safeParse("jane@example").success; // false
Every address below went through all four checks. ✓ means the check accepts the address, ✗ that it rejects it.
| Address | Should pass? | type="email" | Regex | validator.js | Zod |
|---|---|---|---|---|---|
jane@example.com | Yes | ✓ | ✓ | ✓ | ✓ |
jane.doe+news@ | Yes | ✓ | ✓ | ✓ | ✓ |
o'brien@ | Yes | ✓ | ✓ | ✓ | ✓ |
jane@ | Yes | ✓ | ✓ | ✓ | ✓ |
jane@ | Yes | ✓ | ✓ | ✓ | ✓ |
jane | No | ✓ | ✗ | ✗ | ✗ |
.jane@ | No | ✓ | ✗ | ✗ | ✗ |
jane..doe@ | No | ✓ | ✗ | ✗ | ✗ |
jane@example | No | ✓ | ✗ | ✗ | ✗ |
jane@example.c | No | ✓ | ✗ | ✗ | ✗ |
jane@-example.com | No | ✗ | ✗ | ✗ | ✗ |
jane@ | No | ✗ | ✗ | ✗ | ✗ |
jane@ | No | ✗ | ✗ | ✗ | ✗ |
jane doe@example.com | No | ✗ | ✗ | ✗ | ✗ |
"jane doe"@example.com | No (RFC-valid) | ✗ | ✗ | ✓ | ✗ |
jane@[192 | No (RFC-valid) | ✗ | ✗ | ✗ | ✗ |
josé@example.com | No (needs SMTPUTF8) | ✗ | ✗ | ✓ | ✗ |
jane@ | After IDN conversion | ✗ | ✗ | ✓ | ✗ |
aaa…a@ (65 a’s) | No | ✓ | ✗ | ✗ | ✓ |
jane@example.com with spaces around it | After trimming | ✓ | ✗ | ✗ | ✗ |
jane@example.com + line break | No | ✓ | ✗ | ✗ | ✗ |
Tested with Chrome 156, Node.js 26.12, validator 13.15.35 and Zod 4.6.7. The browser’s ✓ in the last two rows comes from its value sanitization: it strips the spaces and the line break before it checks. What the libraries do differently:
- validator.js
isEmail()is the most permissive of the three: with its default options it accepted the quoted local part, the accented local part and the Unicode domain. Options such asallow_utf8_local_part: falsetighten it. - Zod
z.email()uses its own pattern, which allows only letters, digits,_,',+,-and dots in the local part and has no length limit. It rejected the Unicode domain and accepted the 65-character local part.z.email({ pattern: zswitches to the browser’s rule..regexes .html5Email })
Whichever you pick, run the same check in the browser and on the server, so a value the form accepts is never rejected later.
4. Suggest a fix for typos
Many bad sign-ups are typos in a popular domain: gmial.com, hotmial.com, yaho.com. They pass every format check. Suggest the likely address instead of rejecting it:
// suggest.js: runs in the browser and in Node.js
const DOMAINS = [
"gmail.com", "googlemail.com", "yahoo.com", "hotmail.com", "outlook.com", "live.com",
"icloud.com", "aol.com", "mail.com", "gmx.com", "gmx.de", "gmx.at", "gmx.net", "web.de", "proton.me",
];
// Levenshtein distance: how many single-letter edits turn a into b
function distance(a, b) {
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
const row = [i];
for (let j = 1; j <= b.length; j++) {
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
}
prev = row;
}
return prev[b.length];
}
// Returns a corrected address for a likely typo in a common domain, or null.
export function suggestEmail(email) {
const at = email.lastIndexOf("@");
const domain = email.slice(at + 1).toLowerCase();
if (at < 1 || DOMAINS.includes(domain)) return null;
let best = null;
for (const candidate of DOMAINS) {
const d = distance(domain, candidate);
if (d <= 2 && (!best || d < best.d)) best = { candidate, d };
}
return best ? `${email.slice(0, at)}@${best.candidate}` : null;
}
In our run it turned anna@gmial.com into anna@gmail.com, anna@gmail.con into anna@gmail.com and anna@outlok.com into anna@outlook.com, and left anna@mail.com, anna@gmx.de and anna@example.com alone. Real domains that are close to a listed one, such as mail.com next to gmail.com, must be in the list themselves; before we added gmx.at, the function suggested gmx.de for it. That’s why the result should be a question (“Did you mean anna@did_you_mean field.
5. Check the domain’s MX records in Node.js
A valid format says nothing about the domain. Node’s dns module looks up the mail servers:
// mx.js: Node.js
import { Resolver } from "node:dns/promises";
const resolver = new Resolver({ timeout: 3000, tries: 2 });
// true: the domain accepts mail; false: it doesn't; null: DNS gave no clear answer
export async function acceptsMail(domain) {
try {
const mx = await resolver.resolveMx(domain);
if (mx.length === 1 && mx[0].exchange === "") return false; // null MX (RFC 7505)
if (mx.length > 0) return true;
} catch (err) {
if (err.code === "ENOTFOUND") return false; // the domain doesn't exist
if (err.code !== "ENODATA") return null; // timeout, SERVFAIL: unknown, don't reject
}
// No MX record: senders fall back to the domain's A or AAAA record (implicit MX).
const results = await Promise.allSettled([resolver.resolve4(domain), resolver.resolve6(domain)]);
if (results.some((r) => r.status === "fulfilled" && r.value.length > 0)) return true;
return results.every((r) => r.reason?.code === "ENODATA") ? false : null;
}
We pointed the resolver at a local test DNS server with resolver.setServers():
| Domain set up with | resolveMx returned | acceptsMail |
|---|---|---|
| Two MX records | both records | true |
Null MX (0 .) | one record with exchange: "" | false |
| A record only | error ENODATA | true |
| AAAA record only | error ENODATA | true |
| No such domain | error ENOTFOUND | false |
| SERVFAIL | error ESERVFAIL | null |
| No answer | error ETIMEOUT after the timeout | null |
A domain that receives no email publishes a single MX record with the host . (RFC 7505); Node returns it with an empty exchange. example.com is such a domain, which is why test addresses there always bounce. Treat null as “unknown”: a slow DNS server is not a reason to turn away a customer. How MX records work is explained in the MX record guide, and the MX lookup tool checks a domain by hand.
6. Check that the mailbox exists
The MX lookup proves that the domain takes email, not that anna@example.org exists. Only the receiving mail server knows that, and a browser can’t ask it. Asking it from your own server needs outbound port 25, which most cloud providers block, and catch-all domains accept every address anyway. The email validation API runs the syntax, DNS, MX and mailbox checks in one request, without sending an email:
// check-mailbox.js: Node.js 18 or later (built-in fetch)
const API_URL = "https://api.emailvalidation.io/v1/info";
export async function checkMailbox(email) {
const key = process.env.EMAILVALIDATION_KEY;
if (!key) throw new Error("EMAILVALIDATION_KEY is not set");
// timeout=8: the API answers within 8 seconds; the client waits 2 seconds longer.
const params = new URLSearchParams({ email, timeout: "8" });
const res = await fetch(`${API_URL}?${params}`, {
headers: { apikey: key },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
throw new Error(`email validation API: HTTP ${res.status} ${await res.text()}`);
}
return res.json();
}
The key comes from an environment variable and goes in the apikey header, so it never appears in front-end code, URLs or logs. URLSearchParams encodes the + in jane+news@example.org as %2B, so the tag reaches the API intact. The API’s timeout parameter (3 to 60 seconds, 30 by default) caps how long it waits for a slow mail server; the documentation recommends giving your HTTP client a few seconds more. A wrong key comes back as 401, a used-up quota or too many parallel checks as 429, and the function throws for both.
The response has 21 fields. Decide on status, or on recommended_action if you only need the action:
status | Example sub_status | recommended_action | In a sign-up form |
|---|---|---|---|
valid | null, role_based, possible_typo | send, send_ | Accept. Show did_you_mean if it isn’t empty. |
invalid | mailbox_, no_dns_entries, failed_ | suppress | Reject and ask the user to check the address. |
catch_all | accept_all | send_ | Accept. The domain takes every address, so the mailbox can’t be confirmed. |
risky | mailbox_full | retry_later | Accept; the mailbox exists but is full right now. |
do_not_mail | disposable | suppress | Ask for a permanent address. |
unknown | timeout_exceeded, greylisted | retry_later | Accept. The mail server gave no clear answer. |
The older state and reason fields carry the same verdict in three states (deliverable, undeliverable, unknown); the result statuses page maps one to the other. Catch-all email explains why those domains can’t be verified per mailbox.
The official SDK, npm install @everapi/emailvalidation-js, wraps the same request: new Emailvalidation(key).info(email, {}). In version 1.0.6 the second argument is required (pass {}), there is no timeout, and an error answer such as a 401 comes back as a normal result object, so check that the result has a state before you use it.
7. Put it together in an Express route
On the server, the format check runs first and answers at once. The API check runs next and fails open: if the API is slow, down or out of quota, the sign-up goes through.
// server.js: npm install express
import express from "express";
import { isValidEmail } from "./email.js";
import { normalizeEmail } from "./normalize.js";
import { checkMailbox } from "./check-mailbox.js";
const app = express();
app.use(express.urlencoded({ extended: false }));
app.post("/signup", async (req, res) => {
const email = normalizeEmail(req.body?.email ?? "");
if (!isValidEmail(email)) {
return res.status(422).json({ error: "Please enter a valid email address." });
}
let result = null;
try {
result = await checkMailbox(email);
} catch (err) {
console.warn("email check skipped:", err.message); // API down or slow: don't block the sign-up
}
if (result?.status === "invalid") {
return res.status(422).json({ error: "This email address doesn't exist. Please check it." });
}
if (result?.status === "do_not_mail") {
return res.status(422).json({ error: "Please use a permanent email address." });
}
// create the account ...
res.status(201).json({ email, didYouMean: result?.did_you_mean || null });
});
app.listen(3000);
We ran the server with Express 5.3 against a local mock of the API that returns the documented response format, and posted form data to /signup:
Posted email | Response |
|---|---|
anna@example.org | 201 |
anna@example.org with spaces around it | 201, saved as anna@example.org |
jane@ | 201, sent to the API as jane@ |
jane@example | 422, “Please enter a valid email address.” |
An address the API reports as invalid | 422, “This email address doesn’t exist. Please check it.” |
An address the API reports as do_not_mail | 422, “Please use a permanent email address.” |
| An address on a catch-all domain | 201 |
anna@gmial.com | 201 with "didYouMean": "anna@gmail.com" |
| Any address while the API answers 429 | 201, check skipped and logged |
No email field, or the field sent twice | 422 |
The same pattern works in Next.js route handlers, Fastify or a serverless function: format check, API call with a timeout, block only invalid and do_not_mail.
Recommended setup
- In the form:
type="email",requiredandautocomplete="email", plusisValidEmailfor a message as the user types. - When the field loses focus:
suggestEmail, shown as a question. - On the server:
normalizeEmail, thenisValidEmailagain. - On submit: the API check. Block
invalidand disposable addresses, accept the rest, and accept everything when the API can’t be reached. - For an existing list, upload it to the bulk email verifier or send it to the bulk endpoint instead of calling
/v1/infoin a loop. The free plan includes 100 validations a month at up to 10 requests a minute and 5 checks at a time; paid plans have no per-minute limit and run 25 checks at a time (email validation API pricing).
To see the API’s answer for one address before you write any code, paste it into the free email validator. The same checks in other languages: Python email validation and PHP email validation.
Sources
Checked October 29, 2026: HTML Standard: valid email address; MDN, Constraint validation; Node.js documentation: dns, url