Verifica delle email in Next.js 15
Questo tutorial mostra come integrare la verifica delle email di Mailbeam in un modulo di registrazione di Next.js 15 con App Router usando Server Actions, Zod e react-hook-form. La verifica gira sul server: la tua chiave API non arriva mai al browser.
Che cosa costruirai
- Una Server Action
signupActioncon validazione tramite schema di Zod - La verifica delle email con Mailbeam eseguita sul server
- Un modulo di registrazione con errori in linea in tempo reale
- Un riscontro ottimistico nell'interfaccia durante l'invio
Prerequisiti
- Un progetto Next.js 15 o superiore (App Router)
- React 19 o superiore
- Una chiave API di Mailbeam (iscriviti gratis)
Passo 1 — Installa le dipendenze
pnpm add zod react-hook-form @hookform/resolversMailbeam in sé non ha bisogno di alcun pacchetto: è un unico endpoint HTTP. Crea il wrapper usato da questo 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 restituito ${response.status}`);
return response.json();
}Passo 2 — Definisci la variabile d'ambiente
# .env.local
MAILBEAM_KEY=mb_live_xxxxxxxxxxxxxxxxxxxxPasso 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, "L'email è obbligatoria")
.email("Inserisci un indirizzo email valido"),
password: z
.string()
.min(8, "La password deve avere almeno 8 caratteri"),
});
export type SignupActionState = {
success: boolean;
errors?: {
email?: string[];
password?: string[];
general?: string;
};
};
export async function signupAction(
_prev: SignupActionState,
formData: FormData
): Promise<SignupActionState> {
// Analizza e 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 l'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 un indirizzo email permanente, non uno temporaneo."
: "Inserisci un indirizzo email valido e raggiungibile.",
],
},
};
}
} catch {
// Lascia passare: non bloccare la registrazione per un errore di Mailbeam
console.error("Verifica di Mailbeam fallita");
}
// Crea l'utente
try {
await createUser({ email, password });
} catch {
return {
success: false,
errors: { general: "Qualcosa è andato storto. Riprova." },
};
}
redirect("/dashboard");
}Passo 4 — Costruisci il componente del modulo
// 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),
});
// Riporta gli errori del server nel modulo
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"
>
Indirizzo 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"
>
Password
</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 ? "Creazione dell'account…" : "Crea account"}
</button>
</form>
);
}Passo 5 — Usalo nella tua pagina
// 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 il tuo account</h1>
<SignupForm />
</div>
</main>
);
}Prove
Usa i domini di prova deterministici di Mailbeam:
// In sviluppo e nei test: questi non consumano quota
"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"Per provare il modulo in locale:
- Avvia
pnpm dev - Vai su
/signup - Invia
throwaway@mailinator.com— dovrebbe mostrare l'errore in linea sul campo email - Invia
user@valid.mailbeam-test.dev— dovrebbe reindirizzare a/dashboard
Buone pratiche
| Pratica | Perché |
|---|---|
useActionState + useEffect per gli errori | Gli errori del server tornano nel modulo senza ricaricare |
Lasciar passare davanti agli errori di verifyEmail | Un guasto di Mailbeam non rompe la registrazione |
Usare il campo reason per messaggi concreti | «Email temporanea» è più chiaro di «Email non valida» |
aria-invalid + aria-describedby | Stati di errore accessibili ai lettori di schermo |
| Domini di prova in sviluppo | Nessun consumo di quota mentre sviluppi |
Lista di controllo per la produzione
-
MAILBEAM_KEYdefinita come segreto d'ambiente su Vercel (o sulla tua piattaforma) - I messaggi di errore sono chiari e concreti
- Gli attributi
aria-invalidearia-describedbysono impostati sui campi - Il percorso «in caso di errore lascia passare» provato con un errore dell'API simulato