Server-side validation catches bad emails before they hit your database, but by then the user has already submitted the form and is waiting on a response. If you want to tell someone their email looks undeliverable while they're still typing, that's a different problem — and Next.js's API routes make it pretty clean to solve.
Here's how I wired real-time validation into a signup flow, from the client-side debounce to the API route doing the actual check.
Step 1: The Basic Signup Form
Starting point — a simple form with client-side state:
'use client';
import { useState } from 'react';
export default function SignupForm() {
const [email, setEmail] = useState('');
const [emailStatus, setEmailStatus] = useState(null); // 'checking' | 'valid' | 'invalid' | null
return (
<form>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
/>
{emailStatus === 'checking' && <span>Checking...</span>}
{emailStatus === 'invalid' && <span>This email doesn't look deliverable</span>}
{emailStatus === 'valid' && <span>✓ Looks good</span>}
</form>
);
}
Right now nothing actually populates emailStatus. That's the part we're adding.
Step 2: Debouncing the Check
You don't want to fire a validation request on every keystroke. A simple debounce hook handles that:
import { useEffect, useState } from 'react';
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
Then hook it into the form:
const debouncedEmail = useDebounce(email, 600);
useEffect(() => {
if (!debouncedEmail || !debouncedEmail.includes('@')) return;
setEmailStatus('checking');
fetch('/api/verify-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: debouncedEmail }),
})
.then((res) => res.json())
.then((data) => setEmailStatus(data.valid ? 'valid' : 'invalid'))
.catch(() => setEmailStatus(null));
}, [debouncedEmail]);
Now the check fires 600ms after the user stops typing, not on every character.
Step 3: The API Route
This is the Next.js API route the form is calling. Keep the actual validation logic server-side — never expose your validation API key to the client:
// app/api/verify-email/route.js
export async function POST(request) {
const { email } = await request.json();
if (!email) {
return Response.json({ valid: false, reason: 'missing_email' }, { status: 400 });
}
try {
const result = await validateEmail(email);
return Response.json({ valid: result.status !== 'invalid' });
} catch (err) {
console.error('Email validation error:', err.message);
// fail open — don't block signup over a validation service hiccup
return Response.json({ valid: true });
}
}
async function validateEmail(email) {
const response = await fetch(
`https://api.gamalogic.com/v1/verify?email=${encodeURIComponent(email)}&api_key=${process.env.GAMALOGIC_API_KEY}`
);
return response.json();
}
I'm using Gamalogic's real-time email validation API here since it returns catch-all and disposable-domain detection alongside the basic valid/invalid check, which is more than a plain MX lookup gives you.
Step 4: Handling the Loading and Error States Properly
A few things I got wrong on my first pass, worth calling out:
- Don't block form submission on a pending check. If the debounce hasn't resolved yet when the user hits submit, either wait briefly for it or re-validate server-side on submit — don't just let it through silently.
- Fail open, not closed. If the validation API times out or errors, treat the email as valid rather than blocking a real signup over an infrastructure blip. Log the failure so you can investigate separately.
- Debounce delay matters more than it looks. Too short (under 300ms) and you're firing requests on every pause; too long (over 1s) and it feels laggy. 500-600ms was the sweet spot for me.
Step 5: Putting It Together on Submit
async function handleSubmit(e) {
e.preventDefault();
if (emailStatus === 'invalid') {
return; // don't let obviously bad emails through
}
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (res.ok) {
// redirect or show success
}
}
The /api/signup route should still re-run validation server-side rather than trusting the client-side emailStatus — the client check is a UX nicety, not a security boundary.
Takeaway
Real-time validation is mostly a UX problem wrapped around a server-side check you probably already have. Debounce the input, keep the actual validation call server-side, and decide up front how you want to handle a validation-service failure so a slow API doesn't turn into blocked signups.
Top comments (0)