Beta privadaMailbeam está en beta privada: la API pública todavía no está abierta.Únete a la lista de espera

Mailbeam
React + hook propioIntermedio20 minutosActualizado en enero de 2025

Verificación de email en React

Este tutorial construye un hook de verificación de email en tiempo real para React. Mientras la persona escribe su dirección, el hook aplica un debounce a las peticiones a tu backend, que es quien llama a Mailbeam. El resultado gobierna los estados en línea de la interfaz: indicador de carga, mensaje de error o marca verde.

Qué vas a construir

  • Un hook useEmailVerification con debounce
  • Un endpoint de verificación (Route Handler de Next.js o Express)
  • Un componente EmailInput accesible con estado en línea
  • Su integración en un formulario de registro

Requisitos previos

  • Un proyecto de React 18 o superior
  • Un backend al que puedas añadir una ruta
  • Una clave de API de Mailbeam (regístrate gratis)

Paso 1 — Crea la ruta de API

El hook llama a tu backend y este llama a Mailbeam. Así la clave de API se queda en el servidor.

Route Handler de Next.js:

// app/api/verify-email/route.ts
import { NextResponse } from "next/server";
// Todavía no hay SDK: este es el envoltorio de 12 líneas de /docs/quickstart.
import { verifyEmail } from "./lib/mailbeam";

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

  if (!email || typeof email !== "string") {
    return NextResponse.json({ error: "el campo email es obligatorio" }, { status: 400 });
  }

  try {
    const result = await verifyEmail(email);
    return NextResponse.json(result);
  } catch {
    // Falla en abierto: devuelve una respuesta "válida" para no bloquear el formulario
    return NextResponse.json({ valid: true, score: 50, reason: null });
  }
}

Equivalente en Express:

// routes/verifyEmail.js
// Todavía no hay SDK: este es el envoltorio de 12 líneas de /docs/quickstart.
import { verifyEmail } from "./lib/mailbeam";

router.post("/api/verify-email", async (req, res) => {
  const { email } = req.body;
  try {
    const result = await verifyEmail(email);
    res.json(result);
  } catch {
    res.json({ valid: true, score: 50, reason: null }); // falla en abierto
  }
});

Paso 2 — Construye el hook

// hooks/useEmailVerification.ts
import { useState, useEffect, useRef } from "react";

export type VerificationStatus = "idle" | "loading" | "valid" | "invalid";

export interface VerificationResult {
  valid: boolean;
  score: number;
  reason: string | null;
}

export interface UseEmailVerificationReturn {
  status: VerificationStatus;
  result: VerificationResult | null;
  errorMessage: string | null;
}

const REASON_MESSAGES: Record<string, string> = {
  invalid_syntax: "Revisa el formato del email.",
  no_mx_records: "Este dominio no parece aceptar correo.",
  smtp_rejected: "Esta dirección de email no parece existir.",
  disposable_domain: "Usa un email permanente, no uno temporal.",
  role_address: "Usa una dirección de email personal.",
  catch_all_unverifiable: "No hemos podido verificar del todo esta dirección; compruébala.",
};

export function useEmailVerification(
  email: string,
  { debounceMs = 600, minScore = 60 }: { debounceMs?: number; minScore?: number } = {}
): UseEmailVerificationReturn {
  const [status, setStatus] = useState<VerificationStatus>("idle");
  const [result, setResult] = useState<VerificationResult | null>(null);
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    // Reinicia si el email está vacío o parece incompleto
    if (!email || !email.includes("@") || !email.includes(".")) {
      setStatus("idle");
      setResult(null);
      return;
    }

    setStatus("loading");

    const timer = setTimeout(async () => {
      // Cancela cualquier petición en vuelo
      abortRef.current?.abort();
      abortRef.current = new AbortController();

      try {
        const res = await fetch("/api/verify-email", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email }),
          signal: abortRef.current.signal,
        });

        if (!res.ok) throw new Error(`HTTP ${res.status}`);

        const data: VerificationResult = await res.json();
        setResult(data);
        setStatus(data.valid && data.score >= minScore ? "valid" : "invalid");
      } catch (err) {
        if ((err as Error).name === "AbortError") return;
        // Falla en abierto: un error de red no debería bloquear el formulario
        setStatus("idle");
      }
    }, debounceMs);

    return () => {
      clearTimeout(timer);
      abortRef.current?.abort();
    };
  }, [email, debounceMs, minScore]);

  const errorMessage =
    status === "invalid" && result?.reason
      ? (REASON_MESSAGES[result.reason] ?? "Escribe una dirección de email válida.")
      : null;

  return { status, result, errorMessage };
}

Paso 3 — Crea el componente EmailInput

// components/EmailInput.tsx
import { useId } from "react";
import { type UseEmailVerificationReturn } from "@/hooks/useEmailVerification";

interface EmailInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  verification: UseEmailVerificationReturn;
  label?: string;
}

export function EmailInput({
  verification,
  label = "Dirección de email",
  ...inputProps
}: EmailInputProps) {
  const id = useId();
  const errorId = `${id}-error`;
  const { status, errorMessage } = verification;

  return (
    <div className="space-y-1">
      <label
        htmlFor={id}
        className="block text-sm font-medium text-foreground"
      >
        {label}
      </label>

      <div className="relative">
        <input
          id={id}
          type="email"
          autoComplete="email"
          aria-invalid={status === "invalid"}
          aria-describedby={status === "invalid" ? errorId : undefined}
          className={`
            w-full rounded-lg border px-3 py-2 pr-9 text-sm bg-background
            focus:outline-none focus:ring-2 focus:ring-ring
            ${status === "invalid" ? "border-destructive" : ""}
            ${status === "valid" ? "border-green-500" : "border-border"}
          `}
          {...inputProps}
        />

        {/* Indicador de estado */}
        <span
          className="absolute right-3 top-1/2 -translate-y-1/2 text-sm"
          aria-hidden="true"
        >
          {status === "loading" && (
            <span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
          )}
          {status === "valid" && <span className="text-green-500">✓</span>}
          {status === "invalid" && <span className="text-destructive">✗</span>}
        </span>
      </div>

      {errorMessage && (
        <p id={errorId} role="alert" className="text-xs text-destructive">
          {errorMessage}
        </p>
      )}
    </div>
  );
}

Paso 4 — Úsalo en un formulario de registro

// app/signup/page.tsx
"use client";

import { useState } from "react";
import { EmailInput } from "@/components/EmailInput";
import { useEmailVerification } from "@/hooks/useEmailVerification";

export default function SignupPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const verification = useEmailVerification(email);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    // Bloquea el envío mientras la verificación carga o ha fallado
    if (verification.status === "loading" || verification.status === "invalid") {
      return;
    }

    const res = await fetch("/api/auth/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });

    if (res.ok) {
      window.location.href = "/dashboard";
    }
  }

  const canSubmit =
    verification.status === "valid" || verification.status === "idle";

  return (
    <main className="flex min-h-screen items-center justify-center px-4">
      <form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
        <h1 className="text-2xl font-bold">Crear cuenta</h1>

        <EmailInput
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          verification={verification}
        />

        <div>
          <label htmlFor="password" className="block text-sm font-medium mb-1">
            Contraseña
          </label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full rounded-lg border border-border px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
          />
        </div>

        <button
          type="submit"
          disabled={!canSubmit || !email || !password}
          className="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
        >
          Crear cuenta
        </button>
      </form>
    </main>
  );
}

Buenas prácticas

PrácticaPor qué
Debounce de 600 msEspera a que la persona termine de escribir antes de lanzar la petición
Abortar las peticiones en vueloEvita condiciones de carrera cuando el email cambia rápido
Fallar en abierto ante errores de fetchLos problemas de red no deberían bloquear el formulario
aria-invalid + aria-describedbyEstado de error accesible para lectores de pantalla
Condicionar el envío a "valid" | "idle"No permitas enviar a mitad de la verificación

Checklist de producción

  • La ruta de API vive solo en el servidor (la clave no está en el bundle del cliente)
  • Debounce ajustado a tu experiencia de uso (600 ms es un buen punto de partida)
  • Los mensajes de error son accionables («usa un email permanente» en vez de «email inválido»)
  • Formulario accesible: aria-invalid, aria-describedby y roles definidos
  • Fallo en abierto probado: un error de fetch debe seguir permitiendo enviar el formulario

Siguientes pasos