JavaScript Email Validation: Regex, HTML5, and API (2026)

Use AI to summarize this article and ask questions

Grant Ammons
Grant Ammons – Founder May 19, 2026

JavaScript Email Validation: Regex, HTML5, and API (2026)

The complete guide to validating email addresses in JavaScript. Practical regex, HTML5 native validation, and how to verify deliverability with an API.

TL;DR: Use the HTML5 type="email" attribute for free baseline validation, layer a strict regex on top for catching typos, and call a verification API on submit so you reject undeliverable addresses before they hit your database. Client-side checks alone catch typos but never deliverability.

There are three different jobs people mean when they say “validate email in JavaScript”:

  1. Stop a user from submitting bob@gmail.cm before they leave the form
  2. Reject malformed strings before sending them to your backend
  3. Reject undeliverable addresses before they bounce a campaign

The first two are pure client-side string checks. The third requires a network call because no regex on earth can tell you whether bob@example.com is a real mailbox. Most JavaScript email validation tutorials conflate these, which is why so many forms ship with elaborate regex and still accept addresses that bounce the moment you email them.

This guide walks each layer in order, with code that actually works in production.

Layer 1: HTML5 type="email" — free and underused

Before you write a line of JavaScript, give the browser its job. Setting type="email" on an input turns on built-in validation. The browser will block form submission and surface a localized error message if the value doesn’t look like an email.

<form>
  <input type="email" name="email" required />
  <button type="submit">Sign up</button>
</form>

That’s it. No script needed. Chrome, Safari, Firefox, and Edge all check the value against the WHATWG email grammar before the form submits. If you want to enforce a specific domain or character set, you can add a pattern attribute.

<input
  type="email"
  pattern="[^@s]+@[^@s]+.[^@s]+"
  required
/>

HTML5 validation has two real limits:

  • The default error message style varies by browser
  • Permissive parsing — test@a is technically valid per spec but rarely useful

Both are fixable in JavaScript, but the baseline costs nothing.

Layer 2: Regex validation in JavaScript

When you need custom messaging, real-time feedback, or stricter rules than the browser default, reach for a regex. The full RFC 5322 email grammar is famously gnarly — there’s a working RFC 5322-compliant regex that is hundreds of characters long and matches almost nothing real-world users would consider valid. Skip it.

Use a practical regex instead. This one rejects the most common typos without false-positiving on real addresses:

function isValidEmailSyntax(email) {
  if (typeof email !== 'string') return false
  if (email.length > 254) return false  // RFC 5321 SMTP limit

  const pattern = /^[^s@]+@[^s@]+.[^s@]{2,}$/
  return pattern.test(email.trim())
}

What this catches:

  • Missing @
  • Missing domain
  • Missing TLD (bob@gmail)
  • Whitespace anywhere in the address
  • Addresses longer than the SMTP-allowed 254 characters

What it does not catch:

  • Domains that don’t exist (bob@gmail.cm — note the missing o)
  • Disposable inboxes (bob@mailinator.com)
  • Catch-all domains that accept everything but deliver nothing
  • Typos in valid-looking domains (bob@gmial.com)

If you stop at regex, you’ll still get bounces. The next layer is what closes that gap.

Real-time client-side feedback

For the best UX, run the regex on input or blur, not just on submit. Users get an immediate hint instead of a wall of red after they click the button.

const input = document.querySelector('input[type="email"]')
const errorEl = document.querySelector('.email-error')

input.addEventListener('blur', () => {
  const value = input.value.trim()
  if (value && !isValidEmailSyntax(value)) {
    errorEl.textContent = 'Please enter a valid email address.'
    input.setCustomValidity('Invalid email')
  } else {
    errorEl.textContent = ''
    input.setCustomValidity('')
  }
})

setCustomValidity is the bridge between your JS regex and HTML5’s native form validation. When the string is non-empty, the form refuses to submit until the user fixes it.

email-validator npm package vs raw regex vs HTML5 native

The honest comparison most developers want:

Approach Bundle cost Catches typos Catches deliverability When it fits
HTML5 type="email" 0 KB Loose (spec permits a@b) No Marketing forms, newsletter signups where false-negatives matter more than catching every typo
Practical regex (above) ~100 bytes Strong No Anything you control end-to-end — paste it once and forget it
email-validator (npm) ~2 KB Strong No Backends where you want a stable, well-tested package boundary
validator.js isEmail ~20 KB Very strong, RFC-aware No Apps already using validator.js for other checks (URLs, UUIDs, etc.)
Truelist API 1 network call Strong Yes — MX, SMTP, disposable, catch-all Anywhere a bounce costs you (signup, lead gen, transactional)

For most teams: HTML5 + the practical regex on the client, then a verification API on submit. Packages like validator.js are useful when you already pull them in for other checks, but importing 20 KB just to validate an email is overkill if you’re calling a deliverability API anyway. The format-of-email-address rules underneath are the same — strictness changes, not the underlying RFC.

Layer 3: Verify deliverability with an API

This is the layer most JavaScript validation guides skip entirely. Regex tells you a string is shaped like an email. It cannot tell you the inbox exists, the domain has an MX record, or the address is a known spam trap. For that, you need to hit a verification API.

Truelist offers a REST API that runs syntax, MX lookup, SMTP probe, disposable detection, and catch-all detection in a single call. The minimum integration is one fetch from your backend on form submit. Never call the API directly from the browser — your API key would leak.

Backend endpoint (Node.js)

// server.js — runs on your backend, not in the browser
import express from 'express'

const app = express()
app.use(express.json())

app.post('/api/verify-email', async (req, res) => {
  const { email } = req.body

  const url = new URL('https://api.truelist.io/api/v1/verify_inline')
  url.searchParams.set('email', email)

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.TRUELIST_API_KEY}`,
    },
  })

  const data = await response.json()
  const result = data.emails[0]

  res.json({
    valid: result.email_state === 'ok',
    state: result.email_state,
    subState: result.email_sub_state,
  })
})

A few things worth knowing about this endpoint:

  • The email is passed as a query parameter, not a JSON body. You can verify up to 3 addresses at once by passing them space-separated (?email=a@x.com b@y.com).
  • email_state is the headline result: ok, email_invalid, risky, unknown, or accept_all.
  • email_sub_state gives the reason: is_disposable, is_role, failed_mx_check, failed_smtp_check, failed_spam_trap, etc.
  • For low-latency signup flows where SMTP probes feel too slow, add &checks=syntax,mx,disposable,role to skip the SMTP step and get a sub-200ms response. Queue the full SMTP check for an async follow-up.

Frontend hookup

async function verifyEmail(email) {
  const res = await fetch('/api/verify-email', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  })
  if (!res.ok) throw new Error('Verification failed')
  return res.json()
}

document.querySelector('form').addEventListener('submit', async (e) => {
  e.preventDefault()
  const email = e.target.email.value.trim()

  if (!isValidEmailSyntax(email)) {
    showError('Please enter a valid email format.')
    return
  }

  const verification = await verifyEmail(email)
  if (!verification.valid) {
    showError('That email cannot receive mail. Check the address and try again.')
    return
  }

  e.target.submit()
})

This pattern fails fast on syntax (no network call needed) and only spends a verification credit when the syntax is good. The user gets specific feedback — “format wrong” vs “domain doesn’t accept mail” — instead of a generic “invalid email” message.

Debouncing real-time API calls

If you want to verify deliverability as the user types — useful on high-stakes signup pages — debounce the API call so you’re not hitting it on every keystroke.

let debounceTimer
input.addEventListener('input', () => {
  clearTimeout(debounceTimer)
  const value = input.value.trim()

  if (!isValidEmailSyntax(value)) return

  debounceTimer = setTimeout(async () => {
    const { valid, state } = await verifyEmail(value)
    if (!valid) showSubtleWarning(`This address looks ${state}. Double-check?`)
  }, 600)
})

A 600ms debounce is the sweet spot: long enough to avoid most mid-typing calls, short enough to feel responsive.

Canceling stale requests with AbortController

Debouncing handles the keystroke storm but not the race when a slow request beats a fast one. If the user types bob@example.co then bob@example.com, you can apply the first result on top of the second — flashing “invalid” on an address you already confirmed. AbortController cancels the previous in-flight request before firing the next:

let controller
let debounceTimer

input.addEventListener('input', () => {
  clearTimeout(debounceTimer)
  const value = input.value.trim()
  if (!isValidEmailSyntax(value)) return

  debounceTimer = setTimeout(async () => {
    if (controller) controller.abort()
    controller = new AbortController()

    try {
      const res = await fetch('/api/verify-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email: value }),
        signal: controller.signal,
      })
      const { valid, state } = await res.json()
      if (!valid) showSubtleWarning(`This address looks ${state}. Double-check?`)
      else clearWarning()
    } catch (err) {
      if (err.name !== 'AbortError') throw err
    }
  }, 600)
})

AbortError is silently swallowed (it’s expected, not an error), and any non-abort failure still bubbles up. Pair this with a server-side rate limit so a script can’t burn your verification credits by typing fast in a console.

TypeScript validation helpers

On TypeScript, type the result so callers can’t accidentally treat risky as ok:

type EmailState = 'ok' | 'email_invalid' | 'risky' | 'accept_all' | 'unknown'

type EmailSubState =
  | 'is_disposable'
  | 'is_role'
  | 'failed_mx_check'
  | 'failed_smtp_check'
  | 'failed_spam_trap'
  | null

type VerificationResult = {
  valid: boolean
  state: EmailState
  subState: EmailSubState
}

export function isValidEmailSyntax(email: unknown): email is string {
  if (typeof email !== 'string') return false
  if (email.length > 254) return false
  return /^[^s@]+@[^s@]+.[^s@]{2,}$/.test(email.trim())
}

export async function verifyEmail(email: string): Promise<VerificationResult> {
  const res = await fetch('/api/verify-email', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  })
  if (!res.ok) throw new Error('Verification failed')
  return res.json() as Promise<VerificationResult>
}

The email is string predicate narrows unknown inputs into typed strings, and the discriminated EmailState union forces callers to handle risky and accept_all explicitly — exactly the cases that bite you if you only branch on valid.

Framework patterns: React, Vue, form libraries

The patterns above are framework-agnostic on purpose, but most production forms live inside React, Vue, or a form library. Here’s how the same three layers slot in.

React Hook Form + Zod

React Hook Form handles state; Zod handles the schema. Together they replace both the regex check and the custom-validity dance.

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const SignupSchema = z.object({
  email: z
    .string()
    .trim()
    .max(254, 'Email too long')
    .regex(/^[^s@]+@[^s@]+.[^s@]{2,}$/, 'Please enter a valid email'),
})

type SignupValues = z.infer<typeof SignupSchema>

export function SignupForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } =
    useForm<SignupValues>({ resolver: zodResolver(SignupSchema), mode: 'onBlur' })

  const onSubmit = async (values: SignupValues) => {
    const { valid, state } = await verifyEmail(values.email)
    if (!valid) throw new Error(`Address rejected (${state})`)
    // submit to your real endpoint
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        aria-invalid={!!errors.email}
        aria-describedby={errors.email ? 'email-error' : undefined}
        {...register('email')}
      />
      {errors.email && (
        <p id="email-error" role="alert">{errors.email.message}</p>
      )}
      <button disabled={isSubmitting}>Sign up</button>
    </form>
  )
}

mode: 'onBlur' runs the Zod check when the user tabs out — that’s Layer 2. The API call sits in onSubmit so you only spend a verification credit on real submissions. Formik users can wire the same Zod schema through validationSchema for an equivalent result.

Vue, Svelte, Solid

The pattern stays the same: wrap isValidEmailSyntax and verifyEmail in a composable (Vue), a derived/store (Svelte), or a signal (Solid). Run syntax checks on blur, run the API call on submit, and surface the result through whatever reactivity primitive your framework uses. The hard part — three layers, abort handling, accessibility — lives in plain TypeScript functions, not in the framework adapter.

Accessibility: inline errors screen readers can hear

Validation copy is invisible to screen-reader users unless you announce it. Three attributes do the work:

  • aria-invalid="true" on the input when validation fails
  • aria-describedby pointing at the error element so the message reads after the field name
  • aria-live="polite" (or role="alert") on the error container so updates are announced without waiting for focus
<label for="email">Work email</label>
<input
  id="email"
  type="email"
  required
  aria-invalid="false"
  aria-describedby="email-help email-error"
/>
<p id="email-help" class="hint">We'll send a confirmation link.</p>
<p id="email-error" class="error" aria-live="polite"></p>
function setEmailError(message) {
  const input = document.querySelector('#email')
  const errorEl = document.querySelector('#email-error')
  input.setAttribute('aria-invalid', message ? 'true' : 'false')
  errorEl.textContent = message ?? ''
}

Two gotchas: don’t put role="alert" and aria-live on the same element (some screen readers double-announce — pick one), and don’t update the message on every keystroke (the live region will spam). Update on blur, on submit, and when the API call returns.

Common pitfalls

A few things that bite people:

  • Trimming. Always .trim() before validating. Mobile keyboards love to add trailing spaces.
  • Case normalization. The local part of an email is technically case-sensitive per RFC 5321, but in practice every major provider treats it case-insensitively. Lowercase before storing.
  • Plus-addressing. bob+newsletter@gmail.com is a valid Gmail address. Don’t strip the + part.
  • Internationalized domains. Punycode domains like xn--80akhbyknj4f.com will pass a basic regex. If you support international users, don’t reject these.
  • Disposable detection. Services like Mailinator and Guerrilla Mail accept any address. If your business model can’t tolerate those, you need a verification API — regex won’t help.
  • Catch-all servers. Some corporate domains accept mail for any local part. They’ll pass SMTP checks but the recipient may not exist. A good verification API flags these explicitly.

Putting it together

The minimum production-grade JavaScript email validation looks like this:

  1. HTML5 type="email" + required for free baseline validation
  2. Regex check on blur for instant feedback on typos
  3. Verification API call from your backend on submit, before writing the email anywhere

That layered approach catches typos in milliseconds, rejects undeliverable addresses in milliseconds-to-seconds, and keeps your sender reputation intact by never sending to addresses that bounce.

If you want the verification layer without building it yourself, Truelist’s API runs all the deliverability checks in a single request with a 100-call free tier. Plug it into the Node example above and you’re done.

For a closer look at the verification side, the email verification API guide covers the request/response format in detail.

What changed in 2026

A few platform shifts worth knowing:

  • AI form-completion. Chrome and Safari now hand off form fields to AI assistants that auto-paste believable but unverified emails — sometimes from old address books, sometimes synthesized to match a name. Pure regex can’t distinguish “user typed this” from “assistant guessed this.” Treat every email as untrusted on submit and verify deliverability before you write to your database.
  • Consistent HTML5 constraint validation. All major browsers now ship the same WHATWG email grammar. You can rely on :invalid and validity.typeMismatch working identically across browsers.
  • requestSubmit() is universal. Use form.requestSubmit() instead of form.submit() when async validation gates submission — it triggers the full constraint-validation flow including custom validity you set.
  • Smarter spam traps. Bot networks now seed real-looking inboxes that accept mail for weeks before flipping. Verification APIs that score against threat-intel feeds catch traps that pure SMTP probes miss.

Frequently asked questions

Is regex enough to validate an email in JavaScript? For format only, yes — a practical regex catches missing @, missing TLD, whitespace, and length issues in microseconds. For deliverability (will it actually receive mail?), no. You need an API that checks MX records, runs an SMTP probe, and detects disposable inboxes. See the email verification API guide.

Should I use the full RFC 5322 regex? No. It’s hundreds of characters long and matches edge cases like "weird name"@[127.0.0.1] you don’t want in your database. The practical /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ rejects typos and accepts everything real users send.

Can I call a verification API directly from the browser? Don’t. Your API key would leak to anyone who opens DevTools. Proxy through your own backend endpoint, attach the bearer token server-side, and rate-limit per-IP so a script can’t burn your verification credits.

How do I validate emails in React Hook Form / Formik / Zod? Use a Zod schema with the practical regex as the regex constraint, run validation on blur, and call your verification endpoint in the submit handler. The React Hook Form + Zod example above is the canonical pattern.

What about emails with + addressing (bob+newsletter@gmail.com)? Valid — don’t strip the +. Plus-addressing is how Gmail and Fastmail let users tag inbound mail. If you’re deduplicating, normalize separately for matching but store the original. See format of email address for the full rules.

How do I look up an MX record from the browser? You can’t — there’s no DNS API in browsers. MX lookup has to happen on the server. The MX record lookup guide covers the Node and Python implementations; verification APIs like Truelist bundle this into their single response.

Why do emails still bounce after I validate them? Three common reasons: catch-all servers that accept everything but deliver nothing, mailboxes that fill between validation and send, and spam-trap addresses that look legitimate. See why emails bounce back and the bounce checker for measuring it on existing lists.

Ready to put Truelist
to the test?

Find out if Truelist is right for you in under 10 minutes.

Free plan available. No credit card required.