Tutorial · JavaScript & Node.js

JavaScript email validation: from the form field to the mailbox

Five layers of email validation for a JavaScript app: the browser’s own check, a tested regex, typo hints, an MX lookup in Node.js and a mailbox check by API, wired into an Express route.

Updated October 29, 2026 · 12 min read

Validating an email address in JavaScript with a regular expression

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.js. This is the tested pattern from our email regex guide:

// 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.example ") returns jane@xn--bcher-kva.example, which passes. The character check before the conversion matters: domainToASCII("example.com/x") 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.

AddressShould pass?type="email"Regexvalidator.jsZod
jane@example.comYes✓✓✓✓
jane.doe+news@example.comYes✓✓✓✓
o'brien@example.ieYes✓✓✓✓
jane@example.photographyYes✓✓✓✓
jane@xn--bcher-kva.exampleYes✓✓✓✓
jane.@example.comNo✓✗✗✗
.jane@example.comNo✓✗✗✗
jane..doe@example.comNo✓✗✗✗
jane@exampleNo✓✗✗✗
jane@example.cNo✓✗✗✗
jane@-example.comNo✗✗✗✗
jane@exa_mple.comNo✗✗✗✗
jane@example..comNo✗✗✗✗
jane doe@example.comNo✗✗✗✗
"jane doe"@example.comNo (RFC-valid)✗✗✓✗
jane@[192.0.2.10]No (RFC-valid)✗✗✗✗
josé@example.comNo (needs SMTPUTF8)✗✗✓✗
jane@bücher.exampleAfter IDN conversion✗✗✓✗
aaa…a@example.com (65 a’s)No✓✗✗✓
jane@example.com with spaces around itAfter trimming✓✗✗✗
jane@example.com + line breakNo✓✗✗✗

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:

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@gmail.com?”) shown when the field loses focus, never an automatic correction. The email validation API returns its own suggestion in the 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 withresolveMx returnedacceptsMail
Two MX recordsboth recordstrue
Null MX (0 .)one record with exchange: ""false
A record onlyerror ENODATAtrue
AAAA record onlyerror ENODATAtrue
No such domainerror ENOTFOUNDfalse
SERVFAILerror ESERVFAILnull
No answererror ETIMEOUT after the timeoutnull

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:

statusExample sub_statusrecommended_actionIn a sign-up form
validnull, role_based, possible_typosend, send_with_cautionAccept. Show did_you_mean if it isn’t empty.
invalidmailbox_not_found, no_dns_entries, failed_syntax_checksuppressReject and ask the user to check the address.
catch_allaccept_allsend_with_cautionAccept. The domain takes every address, so the mailbox can’t be confirmed.
riskymailbox_fullretry_laterAccept; the mailbox exists but is full right now.
do_not_maildisposablesuppressAsk for a permanent address.
unknowntimeout_exceeded, greylistedretry_laterAccept. 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 emailResponse
anna@example.org201
anna@example.org with spaces around it201, saved as anna@example.org
jane@bücher.example.com201, sent to the API as jane@xn--bcher-kva.example.com
jane@example422, “Please enter a valid email address.”
An address the API reports as invalid422, “This email address doesn’t exist. Please check it.”
An address the API reports as do_not_mail422, “Please use a permanent email address.”
An address on a catch-all domain201
anna@gmial.com201 with "didYouMean": "anna@gmail.com"
Any address while the API answers 429201, check skipped and logged
No email field, or the field sent twice422

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.

  1. In the form: type="email", required and autocomplete="email", plus isValidEmail for a message as the user types.
  2. When the field loses focus: suggestEmail, shown as a question.
  3. On the server: normalizeEmail, then isValidEmail again.
  4. On submit: the API check. Block invalid and disposable addresses, accept the rest, and accept everything when the API can’t be reached.
  5. For an existing list, upload it to the bulk email verifier or send it to the bulk endpoint instead of calling /v1/info in 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.domainToASCII; validator.js and Zod READMEs; RFC 7505 (null MX); emailvalidation.io API documentation: validation endpoint, rate limits. Code run with Node.js 26.12, Chrome 156, validator 13.15.35, Zod 4.6.7 and Express 5.3.0, against a local DNS server and a local mock of the API.

Validate email in other languages

Frequently asked questions

How do I validate an email address in JavaScript?

Trim the input and test it against a regex that follows the format rules, for example the pattern on this page with EMAIL_REGEX.test(value). In a form, add type="email" for instant feedback. A format check can’t tell whether the address exists, so for sign-ups check the mailbox on your server with an email validation API.

What is the best regex for email validation in JavaScript?

One that allows plus tags and apostrophes, rejects dots at the edges of the local part, double dots and domains without a top-level domain, and enforces the 64 and 254 character limits. The pattern in this guide does that; it comes from the email regex guide, where it is tested in six languages. Don’t give it the g flag.

Should I use validator.js or Zod instead of a regex?

Both are fine format checks. In our test, validator.js isEmail() accepted quoted local parts, non-ASCII local parts and Unicode domains, and Zod’s z.email() accepted a 65-character local part. Neither checks whether the domain takes mail or the mailbox exists.

Can JavaScript check if an email address exists?

Not from the browser, which can’t open mail server connections. Node.js can ask the receiving server over SMTP, but outbound port 25 is blocked at most cloud providers and catch-all domains accept every address. An email validation API runs the DNS, MX and mailbox checks and returns one result per address.

Is client-side email validation enough?

No. Anyone can send the form without running your JavaScript, so repeat every check on the server. Client-side validation is for quick feedback; the server decides.

Email validation API

Validate emails in your app

emailvalidation.io checks syntax, MX records and the mailbox over SMTP, flags disposable, role and free addresses and returns a quality score, in one request.

/v1/info Email validation API Read the documentation

100 free validations every month. No credit card required.

GET https://api.emailvalidation.io/v1/info?email=support@emailvalidation.io

{
  "email": "support@emailvalidation.io",
  "user": "support",
  "tag": "",
  "domain": "emailvalidation.io",
  "format_valid": true,
  "mx_found": true,
  "smtp_check": true,
  "catch_all": null,
  "role": true,
  "disposable": false,
  "free": false,
  "score": 0.64,
  "state": "deliverable",
  "reason": "low_quality",
  "did_you_mean": "",
  "catch_all_resolution": null,
  "bounce_probability": null,
  "status": "valid",
  "sub_status": "role_based",
  "explanation": "The mailbox exists, but it is a role address (such as info@ or support@), usually read by a team rather than one person.",
  "recommended_action": "send_with_caution"
}

Keep reading

Start using our email validation software today!

Get 100 validations per month for free