Bêta privéeMailbeam est en bêta privée : l'API publique n'est pas encore ouverte.Rejoindre la liste d'attente

Mailbeam
Next.js 15 + Server ActionsIntermédiaire20 minutesMis à jour en janvier 2025

Vérification d'e-mails dans Next.js 15

Ce tutoriel montre comment intégrer la vérification Mailbeam à un formulaire d'inscription Next.js 15 en App Router, avec les Server Actions, Zod et react-hook-form. La vérification s'exécute côté serveur : votre clé d'API n'atteint jamais le navigateur.

Ce que vous allez construire

  • Une Server Action signupAction avec validation par schéma Zod
  • La vérification d'adresse via Mailbeam, exécutée sur le serveur
  • Un formulaire d'inscription qui affiche les erreurs en ligne, en temps réel
  • Un retour d'interface optimiste pendant l'envoi

Prérequis


Étape 1 — Installer les dépendances

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

Mailbeam lui-même ne demande aucun paquet : c'est un unique endpoint HTTP. Créez le wrapper utilisé par ce tutoriel :

// 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 a renvoyé ${response.status}`);
  return response.json();
}

Étape 2 — Déclarer la variable d'environnement

# .env.local
MAILBEAM_KEY=mb_live_xxxxxxxxxxxxxxxxxxxx

Étape 3 — Créer 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'adresse e-mail est requise")
    .email("Merci de saisir une adresse e-mail valide"),
  password: z
    .string()
    .min(8, "Le mot de passe doit faire au moins 8 caractères"),
});

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

export async function signupAction(
  _prev: SignupActionState,
  formData: FormData
): Promise<SignupActionState> {
  // Lecture et validation avec 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;

  // Vérification de l'adresse avec Mailbeam
  try {
    const verification = await verifyEmail(email);

    if (!verification.valid || verification.score < 60) {
      return {
        success: false,
        errors: {
          email: [
            verification.reason === "disposable_domain"
              ? "Merci d'utiliser une adresse e-mail permanente, pas une adresse temporaire."
              : "Merci de fournir une adresse e-mail valide et joignable.",
          ],
        },
      };
    }
  } catch {
    // Échec permissif — ne bloquez pas l'inscription sur une erreur Mailbeam
    console.error("Échec de la vérification Mailbeam");
  }

  // Création de l'utilisateur
  try {
    await createUser({ email, password });
  } catch {
    return {
      success: false,
      errors: { general: "Une erreur est survenue. Merci de réessayer." },
    };
  }

  redirect("/dashboard");
}

Étape 4 — Construire le composant de formulaire

// 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),
  });

  // Reporte les erreurs serveur dans le formulaire
  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"
        >
          Adresse e-mail
        </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"
        >
          Mot de passe
        </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 ? "Création du compte…" : "Créer le compte"}
      </button>
    </form>
  );
}

Étape 5 — L'utiliser dans votre page

// 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">Créez votre compte</h1>
        <SignupForm />
      </div>
    </main>
  );
}

Tester

Utilisez les domaines de test déterministes de Mailbeam :

// En développement et en test — ils n'entament pas votre 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"

Pour tester le formulaire en local :

  1. Lancez pnpm dev
  2. Rendez-vous sur /signup
  3. Soumettez throwaway@mailinator.com — l'erreur doit s'afficher sous le champ
  4. Soumettez user@valid.mailbeam-test.dev — vous devez être redirigé vers /dashboard

Bonnes pratiques

PratiquePourquoi
useActionState + useEffect pour les erreursLes erreurs serveur reviennent dans le formulaire sans rechargement
Échec permissif sur les erreurs de verifyEmailUne indisponibilité de Mailbeam ne casse pas l'inscription
Se servir du champ reason pour des messages précis« Adresse temporaire » est plus clair que « Adresse invalide »
aria-invalid et aria-describedbyÉtats d'erreur accessibles aux lecteurs d'écran
Domaines de test en développementAucune consommation de quota pendant le développement

Liste de contrôle avant la mise en production

  • MAILBEAM_KEY déclarée comme secret d'environnement sur Vercel (ou votre plateforme)
  • Messages d'erreur clairs et précis
  • Attributs aria-invalid et aria-describedby présents sur les champs
  • Chemin d'échec permissif testé avec une erreur d'API simulée

Prochaines étapes