Beta privataMailbeam è in beta privata: l'API pubblica non è ancora aperta.Iscriviti alla lista d'attesa

Mailbeam
Next.js 15 + Server ActionsIntermedio20 minutiAggiornato a gennaio 2025

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 signupAction con 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/resolvers

Mailbeam 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_xxxxxxxxxxxxxxxxxxxx

Passo 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:

  1. Avvia pnpm dev
  2. Vai su /signup
  3. Invia throwaway@mailinator.com — dovrebbe mostrare l'errore in linea sul campo email
  4. Invia user@valid.mailbeam-test.dev — dovrebbe reindirizzare a /dashboard

Buone pratiche

PraticaPerché
useActionState + useEffect per gli erroriGli errori del server tornano nel modulo senza ricaricare
Lasciar passare davanti agli errori di verifyEmailUn 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-describedbyStati di errore accessibili ai lettori di schermo
Domini di prova in sviluppoNessun consumo di quota mentre sviluppi

Lista di controllo per la produzione

  • MAILBEAM_KEY definita come segreto d'ambiente su Vercel (o sulla tua piattaforma)
  • I messaggi di errore sono chiari e concreti
  • Gli attributi aria-invalid e aria-describedby sono impostati sui campi
  • Il percorso «in caso di errore lascia passare» provato con un errore dell'API simulato

Prossimi passi