DEV Community

Cover image for Integrating Real-Time Email Validation into a Next.js Signup Flow
Arjun CS
Arjun CS

Posted on

Integrating Real-Time Email Validation into a Next.js Signup Flow

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)