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

Mailbeam
Next.js 15 + Server ActionsIntermedio20 minutosActualizado en enero de 2025

Verificación de email en Next.js 15

Este tutorial muestra cómo integrar la verificación de email de Mailbeam en un formulario de registro de Next.js 15 con App Router usando Server Actions, Zod y react-hook-form. La verificación se ejecuta en el servidor: tu clave de API nunca llega al navegador.

Qué vas a construir

  • Una Server Action signupAction con validación por esquema de Zod
  • Verificación de email con Mailbeam ejecutándose en el servidor
  • Un formulario de registro con errores en línea en tiempo real
  • Feedback optimista en la interfaz durante el envío

Requisitos previos

  • Un proyecto de Next.js 15 o superior (App Router)
  • React 19 o superior
  • Una clave de API de Mailbeam (regístrate gratis)

Paso 1 — Instala las dependencias

pnpm add zod react-hook-form @hookform/resolvers

Mailbeam en sí no necesita ningún paquete: es un único endpoint HTTP. Crea el envoltorio que usa este tutorial:

// lib/mailbeam.ts
export interface VerifyResult {
  valid: boolean;
  score: number;
  reason: string | null;
}

export async function verifyEmail(email: string): Promise<VerifyResult> {
  const response = await fetch("https://api.mailbeam.dev/v1/verify", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILBEAM_KEY!}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email }),
    signal: AbortSignal.timeout(3000),
  });

  if (!response.ok) throw new Error(`Mailbeam ha devuelto ${response.status}`);
  return response.json();
}

Paso 2 — Define la variable de entorno

# .env.local
MAILBEAM_KEY=mb_live_xxxxxxxxxxxxxxxxxxxx

Paso 3 — Crea la Server Action

// app/signup/actions.ts
"use server";

import { redirect } from "next/navigation";
import { z } from "zod";
import { verifyEmail } from "@/lib/mailbeam";

const signupSchema = z.object({
  email: z
    .string()
    .min(1, "El email es obligatorio")
    .email("Escribe una dirección de email válida"),
  password: z
    .string()
    .min(8, "La contraseña debe tener al menos 8 caracteres"),
});

export type SignupActionState = {
  success: boolean;
  errors?: {
    email?: string[];
    password?: string[];
    general?: string;
  };
};

export async function signupAction(
  _prev: SignupActionState,
  formData: FormData
): Promise<SignupActionState> {
  // Analiza y valida con Zod
  const result = signupSchema.safeParse({
    email: formData.get("email"),
    password: formData.get("password"),
  });

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }

  const { email, password } = result.data;

  // Verifica el email con Mailbeam
  try {
    const verification = await verifyEmail(email);

    if (!verification.valid || verification.score < 60) {
      return {
        success: false,
        errors: {
          email: [
            verification.reason === "disposable_domain"
              ? "Usa una dirección de email permanente, no una temporal."
              : "Escribe una dirección de email válida y alcanzable.",
          ],
        },
      };
    }
  } catch {
    // Falla en abierto: no bloquees el registro por un error de Mailbeam
    console.error("Ha fallado la verificación de Mailbeam");
  }

  // Crea el usuario
  try {
    await createUser({ email, password });
  } catch {
    return {
      success: false,
      errors: { general: "Algo ha salido mal. Inténtalo de nuevo." },
    };
  }

  redirect("/dashboard");
}

Paso 4 — Construye el componente del formulario

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

import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { signupAction, type SignupActionState } from "./actions";

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

type FormValues = z.infer<typeof schema>;

const initialState: SignupActionState = { success: false };

export function SignupForm() {
  const [state, action, isPending] = useActionState(signupAction, initialState);

  const {
    register,
    formState: { errors },
    setError,
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });

  // Refleja los errores del servidor en el formulario
  useEffect(() => {
    if (state.errors?.email) {
      setError("email", { message: state.errors.email[0] });
    }
    if (state.errors?.password) {
      setError("password", { message: state.errors.password[0] });
    }
  }, [state.errors, setError]);

  return (
    <form action={action} className="space-y-4 max-w-sm">
      {state.errors?.general && (
        <div role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
          {state.errors.general}
        </div>
      )}

      <div>
        <label
          htmlFor="email"
          className="block text-sm font-medium text-foreground mb-1"
        >
          Dirección de email
        </label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          {...register("email")}
          className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
          aria-describedby={errors.email ? "email-error" : undefined}
          aria-invalid={!!errors.email}
        />
        {errors.email && (
          <p id="email-error" role="alert" className="mt-1 text-xs text-destructive">
            {errors.email.message}
          </p>
        )}
      </div>

      <div>
        <label
          htmlFor="password"
          className="block text-sm font-medium text-foreground mb-1"
        >
          Contraseña
        </label>
        <input
          id="password"
          type="password"
          autoComplete="new-password"
          {...register("password")}
          className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
          aria-describedby={errors.password ? "password-error" : undefined}
          aria-invalid={!!errors.password}
        />
        {errors.password && (
          <p id="password-error" role="alert" className="mt-xs text-destructive">
            {errors.password.message}
          </p>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-60"
        aria-busy={isPending}
      >
        {isPending ? "Creando la cuenta…" : "Crear cuenta"}
      </button>
    </form>
  );
}

Paso 5 — Úsalo en tu página

// app/signup/page.tsx
import { SignupForm } from "./SignupForm";

export default function SignupPage() {
  return (
    <main className="flex min-h-screen items-center justify-center px-4">
      <div className="w-full max-w-sm">
        <h1 className="text-2xl font-bold text-foreground mb-6">Crea tu cuenta</h1>
        <SignupForm />
      </div>
    </main>
  );
}

Pruebas

Usa los dominios de prueba deterministas de Mailbeam:

// En desarrollo y en tests: estos no consumen cuota
"user@valid.mailbeam-test.dev"       // → valid: true, score: 99
"user@invalid.mailbeam-test.dev"     // → valid: false
"temp@disposable.mailbeam-test.dev"  // → valid: false, reason: "disposable_domain"

Para probar el formulario en local:

  1. Arranca pnpm dev
  2. Ve a /signup
  3. Envía throwaway@mailinator.com — debería mostrar el error en línea del campo email
  4. Envía user@valid.mailbeam-test.dev — debería redirigir a /dashboard

Buenas prácticas

PrácticaPor qué
useActionState + useEffect para los erroresLos errores del servidor se reflejan en el formulario sin recargar
Fallar en abierto ante errores de verifyEmailUna caída de Mailbeam no rompe el registro
Usar el campo reason para mensajes concretos«Email temporal» es más claro que «Email inválido»
aria-invalid + aria-describedbyEstados de error accesibles para lectores de pantalla
Dominios de prueba en desarrolloSin consumo de cuota mientras desarrollas

Checklist de producción

  • MAILBEAM_KEY definida como secreto de entorno en Vercel (o tu plataforma)
  • Los mensajes de error son claros y concretos
  • Los atributos aria-invalid y aria-describedby están puestos en los inputs
  • La ruta de fallo en abierto probada con un error de API simulado

Siguientes pasos