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
signupActioncon 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/resolversMailbeam 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_xxxxxxxxxxxxxxxxxxxxPaso 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:
- Arranca
pnpm dev - Ve a
/signup - Envía
throwaway@mailinator.com— debería mostrar el error en línea del campo email - Envía
user@valid.mailbeam-test.dev— debería redirigir a/dashboard
Buenas prácticas
| Práctica | Por qué |
|---|---|
useActionState + useEffect para los errores | Los errores del servidor se reflejan en el formulario sin recargar |
Fallar en abierto ante errores de verifyEmail | Una 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-describedby | Estados de error accesibles para lectores de pantalla |
| Dominios de prueba en desarrollo | Sin consumo de cuota mientras desarrollas |
Checklist de producción
-
MAILBEAM_KEYdefinida como secreto de entorno en Vercel (o tu plataforma) - Los mensajes de error son claros y concretos
- Los atributos
aria-invalidyaria-describedbyestán puestos en los inputs - La ruta de fallo en abierto probada con un error de API simulado