Email validation in signup forms: from regex to API
Every bad email address in your subscriber list costs money. Not abstract money: your ESP charges per send, bounce rate climbs, domain reputation drops. Most of those bad addresses come in through signup forms. Filtering garbage at the door is cheaper than cleaning up later.
Three validation layers: fast to accurate
Email validation in a form is not one regex check. Reliable verification runs three layers, each catching a different class of problem.
Layer 1: client-side (regex). Runs instantly, no network round-trip. Catches obvious errors: empty field, missing @, spaces, consecutive dots in the domain. It will not catch nonexistent mailboxes or disposable services, but it gives the user immediate feedback.
Layer 2: server-side (business logic). Backend checks run on form submit: normalization (gmail.com vs googlemail.com), deduplication, lookup against your internal domain blocklist.
Layer 3: external API (real-time verification). A call to a validation service that checks MX records, opens an SMTP connection, identifies catch-all servers, and classifies disposable addresses. The most accurate layer. Takes 1-3 seconds, so it runs asynchronously once the user leaves the field.
All three work together. Regex catches typos before any request leaves the browser. The server filters duplicates and known junk domains. The API verifies what made it past the first two filters.
Client-side validation: regex and UX
Your regex does not need to be perfect. RFC 5322 allows addresses like "quoted string"@example.com, but those almost never appear in practice. A simple pattern covering 99.9% of real addresses:
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function validateEmailFormat(email) {
if (!email) return 'Please enter an email address';
if (!EMAIL_RE.test(email.trim())) return 'Invalid email format';
return null; // no errors
}This regex does not try to handle every RFC edge case. It checks the minimum: something before @, something after, a dot, and a TLD of at least two characters. Good enough for the first filter.
A common mistake: showing an error while the user is still typing. The address user@ is invalid, but the person has not finished yet. A red message at that moment is just friction. Validate on blur (user left the field) or with a 300-500 ms debounce.
const emailInput = document.getElementById('email');
const errorEl = document.getElementById('email-error');
emailInput.addEventListener('blur', () => {
const error = validateEmailFormat(emailInput.value);
errorEl.textContent = error || '';
emailInput.classList.toggle('border-red-400', !!error);
});The same rule applies in React or Vue: validate on blur, not on every keystroke. The error appears when the user finishes typing and disappears as soon as they start correcting.
Server-side validation: normalization and deduplication
Client-side validation can be bypassed by anyone who opens DevTools and sends a POST request directly. Server-side checks are not optional, even if the frontend is airtight.
Add normalization on the server. Users type John.Doe@Gmail.Com without knowing that domain case is irrelevant and Gmail ignores dots in the local part. Without normalization, the same person can register twice.
function normalizeEmail(email) {
let [local, domain] = email.trim().toLowerCase().split('@');
// Gmail: dots in the local part are ignored
if (domain === 'gmail.com' || domain === 'googlemail.com') {
local = local.replace(/\./g, '').split('+')[0];
domain = 'gmail.com';
}
return local + '@' + domain;
}
// normalizeEmail('John.Doe+spam@Gmail.Com')
// => 'johndoe@gmail.com'You can also check the domain against a known disposable-service blocklist on the server. There are hundreds of such domains, and open-source lists update regularly. Relying on lists alone is risky, though: new disposable domains appear every day. That is what the third layer is for.
Real-time verification via API
An external validation API does what the browser cannot: checks MX records, opens an SMTP connection, identifies catch-all servers, classifies disposable addresses. Results come back in 1-3 seconds.
Integration flow: the user fills in the email field and moves on. The frontend sends a request to your backend. Your backend calls the validation API. Based on the result, you show a message or block form submission.
Call the API from your backend, not from the browser. The reason is simple: the API key must not appear in client-side code. Any user can open the Network tab, read the key, and use it for their own purposes.
A backend endpoint in Node.js (Express):
// POST /api/validate-email
app.post('/api/validate-email', async (req, res) => {
const { email } = req.body;
// Layer 1: quick format check
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
return res.json({ valid: false, reason: 'invalid_format' });
}
try {
// Layer 3: call the validation API
const response = await fetch(
'https://api.uchecker.net/api/v1/validate/single',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.UCHECKER_API_KEY,
},
body: JSON.stringify({ email }),
}
);
const data = await response.json();
return res.json({
valid: data.result === 'deliverable',
reason: data.result, // deliverable | undeliverable | risky | unknown
disposable: data.disposable,
catchAll: data.catch_all,
});
} catch (err) {
// API unavailable — let the address through, verify later
console.error('Validation API error:', err.message);
return res.json({ valid: true, reason: 'api_unavailable' });
}
});The catch block matters. If the validation API is temporarily down, the form must not break. Better to let a questionable address through and verify it later via a batch job than to show the user a server error. Graceful degradation is critical for conversion.
Frontend: calling the backend and handling the response
On the client you need to solve two problems: send the request at the right moment, and show the result without annoying the user. The pattern that works: debounce + loading state + clear messages.
const emailField = document.getElementById('email');
const statusEl = document.getElementById('email-status');
let debounceTimer = null;
let controller = null; // AbortController to cancel previous requests
emailField.addEventListener('blur', () => {
const email = emailField.value.trim();
const formatError = validateEmailFormat(email);
if (formatError) {
showStatus(formatError, 'error');
return;
}
// Show a checking indicator
showStatus('Checking...', 'loading');
// Cancel any in-flight request
if (controller) controller.abort();
controller = new AbortController();
fetch('/api/validate-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controller.signal,
})
.then(r => r.json())
.then(data => {
if (data.valid) {
showStatus('', 'ok');
} else if (data.reason === 'undeliverable') {
showStatus('This email does not exist. Please check the address.', 'error');
} else if (data.disposable) {
showStatus('Disposable email addresses are not accepted.', 'error');
} else {
showStatus('Could not verify this email. Try a different address.', 'warn');
}
})
.catch(err => {
if (err.name !== 'AbortError') {
showStatus('', 'ok'); // do not block on network errors
}
});
});
function showStatus(message, type) {
statusEl.textContent = message;
statusEl.className = {
error: 'text-red-600 text-sm mt-1',
warn: 'text-amber-600 text-sm mt-1',
loading: 'text-gray-400 text-sm mt-1',
ok: '',
}[type] || '';
}The AbortController is important. If the user moves between fields quickly or edits their email, the previous request is cancelled. Without it, responses arrive out of order and the status can reflect a value the user already changed.
React example
In React the logic is the same, wrapped in a hook. An email field component with validation:
import { useState, useRef, useCallback } from 'react';
function useEmailValidation() {
const [status, setStatus] = useState('idle');
// idle | checking | valid | invalid | error
const [message, setMessage] = useState('');
const controllerRef = useRef(null);
const validate = useCallback(async (email) => {
// Format check
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
setStatus('invalid');
setMessage('Invalid email format');
return;
}
setStatus('checking');
setMessage('');
if (controllerRef.current) controllerRef.current.abort();
controllerRef.current = new AbortController();
try {
const res = await fetch('/api/validate-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controllerRef.current.signal,
});
const data = await res.json();
if (data.valid) {
setStatus('valid');
setMessage('');
} else {
setStatus('invalid');
setMessage(
data.disposable
? 'Disposable email addresses are not accepted'
: 'This email address is not reachable'
);
}
} catch (err) {
if (err.name !== 'AbortError') {
setStatus('error');
setMessage('');
}
}
}, []);
return { status, message, validate };
}Using it in a form component:
function SignupForm() {
const { status, message, validate } = useEmailValidation();
const [email, setEmail] = useState('');
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
onBlur={() => email && validate(email)}
className={status === 'invalid' ? 'border-red-400' : ''}
/>
{status === 'checking' && <span>Checking...</span>}
{message && <span className="text-red-600">{message}</span>}
<button
type="submit"
disabled={status === 'checking' || status === 'invalid'}
>
Sign up
</button>
</form>
);
}UX patterns: what works and what annoys people
Technical correctness without thought-out UX does more harm than good. These observations come from A/B tests on signup forms.
Do not block input during verification. The user filled in their email and moved to the password field. Verification runs in the background. Block the form with a spinner for two seconds and conversion drops. Let the person continue filling in the form; the validation result can appear when it is ready.
Specific error messages. "Invalid email" is useless. "The domain gmail.co does not exist. Did you mean gmail.com?" actually helps. The more precise the message, the more likely the user corrects the mistake rather than abandoning the form.
Suggestions for common typos. Domains like gmial.com, yandex.tu, hotmal.com show up more often than you would expect. A function using Levenshtein distance, or even a hardcoded list of 20 popular domains, saves dozens of registrations a day on any site with meaningful traffic.
const COMMON_DOMAINS = [
'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com',
'mail.ru', 'yandex.ru', 'rambler.ru', 'icloud.com',
];
function suggestDomain(inputDomain) {
const domain = inputDomain.toLowerCase();
if (COMMON_DOMAINS.includes(domain)) return null;
for (const known of COMMON_DOMAINS) {
if (levenshtein(domain, known) <= 2) {
return known; // "gmial.com" => suggest "gmail.com"
}
}
return null;
}Visual status, not just text. A green checkmark next to the field when the email is valid. Red border on error. Gray spinner while checking. Visual signals register faster than text.
Do not silently reject risky addresses. If the API returns "risky", show a warning but let the user proceed. Some corporate addresses on catch-all domains fall into that category. A hard block on risky addresses will turn away real users.
Error handling and edge cases
The API can return an error. The network can go down. A timeout can fire. Each case needs handling, or the form breaks in production.
Request timeout. Set a timeout of 5-8 seconds. If the API does not respond in time, let the user through and queue the address for a deferred check.
async function validateWithTimeout(email, timeoutMs = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch('/api/validate-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
signal: controller.signal,
});
clearTimeout(timer);
return await res.json();
} catch (err) {
clearTimeout(timer);
// Timeout or network error — pass the address through
return { valid: true, reason: 'timeout' };
}
}Rate limiting. On a high-traffic site, cache results. If a user enters the same address twice, the second API call is unnecessary.
const validationCache = new Map();
async function cachedValidate(email) {
const normalized = email.trim().toLowerCase();
if (validationCache.has(normalized)) {
return validationCache.get(normalized);
}
const result = await validateWithTimeout(normalized);
validationCache.set(normalized, result);
// Keep the cache bounded
if (validationCache.size > 500) {
const firstKey = validationCache.keys().next().value;
validationCache.delete(firstKey);
}
return result;
}Retries. For HTTP 429 and 5xx responses, one retry with a 1-2 second delay is reasonable. More than one retry for a signup form is too slow: the user will not wait.
API fallback. If the validation API is down, the form must still work. Drop the third layer and rely on the first two. The address enters your database without full verification, but you can run bulk validation later. Losing a signup is worse than accepting one questionable email.
Which API response fields to use
A validation API returns several fields. Not all of them are equally useful in a signup form. Here is what to check first:
result (deliverable / undeliverable / risky / unknown). The primary status. For undeliverable, show an error and block submission. For risky, show a warning. For unknown, let it through.
disposable (true / false). If your product requires email confirmation, disposable addresses are useless: the mailbox will be gone within an hour. Block or warn.
catch_all (true / false). The domain accepts mail for any address, so the mailbox may or may not exist. For a signup form, passing the address through with a database flag is enough.
reason. Detailed cause of failure: mailbox_not_found, domain_not_found, smtp_error. Useful for logging and debugging. No need to show it to the user, but it tells the developer exactly why a specific address was rejected.
When to call the API: three strategies
The right choice depends on your form conversion rate and traffic volume.
On blur (recommended). Verification fires when the user leaves the email field. By the time they click "Sign up", the result is already ready. A good balance between fast feedback and API call count.
On submit. Verification only when the form is submitted. Saves API calls: the validation runs once instead of multiple times if the user edits the address. The downside is a delay before the next screen. Suitable for low-traffic forms.
Deferred. The form accepts any email that passes the regex. Full verification runs in the background after registration. Invalid addresses are flagged and the user receives a confirmation request. The lightest approach for conversion, but junk addresses do enter the database.
Security: what not to do
Never call the validation API directly from the browser. The API key ends up in client-side code, and anyone can extract it. All calls to external APIs go through your backend.
Add rate limiting to your /api/validate-email endpoint. Without it, a bot can send thousands of requests and drain your API credits in minutes. 5-10 requests per minute per IP is a reasonable ceiling.
Use CAPTCHA or honeypot fields to block bots. Email validation does not replace protection against automated signups: a bot can submit valid addresses by the thousand.
Integration checklist
A short list of what needs to be in place for solid email validation in a signup form:
1. Client-side regex (instant feedback).
2. Server-side normalization (lowercase, Gmail dots, +alias).
3. API call from the backend only (key never in the browser).
4. 5-8 second timeout on API requests.
5. Graceful degradation: form works when the API is down.
6. Result caching (no second API call for the same address).
7. Rate limit on the validation endpoint (5-10 req/min per IP).
8. Specific error messages (not just "invalid email").
9. Domain typo suggestions.
10. Deferred bulk verification for addresses accepted without full validation.
Ten items. Most of this takes one day to implement. The payoff: less junk in the database, lower bounce rate, better domain reputation, more email reaching the inbox.
Filtering garbage at the door costs ten times less than dealing with a dirty database. One day of integration saves months of deliverability headaches.
Form-level validation does not replace periodic full-list checks. Addresses go stale: people change jobs, providers delete inactive mailboxes. But with a working input filter, the database degrades much more slowly, and a quarterly full scan may be enough instead of monthly.
Try the validation API: sign up at uChecker and get 30 free verifications to test your integration.
