The API route

// app/api/verify-email/route.ts
import { NextRequest, NextResponse } from 'next/server';

const API_KEY = process.env.ROCKETVERIFIER_API_KEY!; // server-side only, never exposed to the client

export async function POST(req: NextRequest) {
  const { email } = await req.json();

  if (!email) {
    return NextResponse.json({ error: 'Email is required' }, { status: 400 });
  }

  const response = await fetch('https://api.rocketverifier.com/v1/verify', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email }),
  });

  if (!response.ok) {
    return NextResponse.json({ error: 'Verification temporarily unavailable' }, { status: 502 });
  }

  const { data } = await response.json();
  return NextResponse.json({
    status: data.status,
    isDisposable: data.domain.disposable,
    isCatchAll: data.domain.acceptAll,
  });
}

The signup form component

// app/signup/SignupForm.tsx
'use client';

import { useState } from 'react';

type EmailCheck = { status: string; isDisposable: boolean; isCatchAll: boolean } | null;

export default function SignupForm() {
  const [email, setEmail] = useState('');
  const [check, setCheck] = useState<EmailCheck>(null);
  const [checking, setChecking] = useState(false);

  async function handleBlur() {
    if (!email) return;
    setChecking(true);
    try {
      const res = await fetch('/api/verify-email', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      });
      const data = await res.json();
      setCheck(data);
    } finally {
      setChecking(false);
    }
  }

  const warning =
    check?.status === 'undeliverable'
      ? "This email address doesn't look deliverable — double-check it."
      : check?.isDisposable
      ? 'Please use a permanent email address.'
      : null;

  return (
    <form method="post" action="/api/signup">
      <label htmlFor="email">Email</label>
      <input
        id="email"
        name="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        onBlur={handleBlur}
        aria-invalid={Boolean(warning)}
      />
      {checking && <p className="text-sm text-gray-500">Checking...</p>}
      {warning && <p className="text-sm text-red-600">{warning}</p>}
      <button type="submit" disabled={check?.status === 'undeliverable'}>
        Sign up
      </button>
    </form>
  );
}

Testing against the sandbox

Set ROCKETVERIFIER_API_KEY=rv_test_your_sandbox_key in .env.local during development and type an @sandbox.rocketverifier.com address into the form to see the inline warning states render correctly, without needing a live key or spending real credits.

Why this still needs server-side verification too

Client-side, on-blur verification is a UX improvement, not a security boundary — a user can always bypass client-side JavaScript. Always re-verify (or at minimum re-check disposable/undeliverable status) in your actual signup-processing endpoint before creating an account, the same way the Express/Node recipe does server-side.