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”:
- Stop a user from submitting
bob@gmail.cmbefore they leave the form - Reject malformed strings before sending them to your backend
- 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@ais 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 missingo) - 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_stateis the headline result:ok,email_invalid,risky,unknown, oraccept_all.email_sub_stategives 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,roleto 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 failsaria-describedbypointing at the error element so the message reads after the field namearia-live="polite"(orrole="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.comis a valid Gmail address. Don’t strip the+part. - Internationalized domains. Punycode domains like
xn--80akhbyknj4f.comwill 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:
- HTML5
type="email"+requiredfor free baseline validation - Regex check on blur for instant feedback on typos
- 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
:invalidandvalidity.typeMismatchworking identically across browsers. requestSubmit()is universal. Useform.requestSubmit()instead ofform.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.
