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.