Mailbeam
Next.js 15 + Server ActionsIntermediate20 minutesUpdated January 2025

Email Verification in Next.js 15

This tutorial shows how to integrate Mailbeam email verification into a Next.js 15 App Router signup form using Server Actions, Zod, and react-hook-form. The verification runs server-side — your API key never reaches the browser.

What you'll build

  • A signupAction Server Action with Zod schema validation
  • Email verification via Mailbeam running on the server
  • A signup form with real-time inline error display
  • Optimistic UI feedback during submission

Prerequisites

  • Next.js 15+ project (App Router)
  • React 19+
  • A Mailbeam API key (sign up free)

Step 1 — Install dependencies

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

Step 2 — Set environment variable

# .env.local
MAILBEAM_KEY=mb_live_xxxxxxxxxxxxxxxxxxxx

Step 3 — Create the Server Action

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

import { redirect } from "next/navigation";
import { z } from "zod";
import Mailbeam from "@mailbeam/sdk";

const mb = new Mailbeam({ apiKey: process.env.MAILBEAM_KEY! });

const signupSchema = z.object({
  email: z
    .string()
    .min(1, "Email is required")
    .email("Please enter a valid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters"),
});

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

export async function signupAction(
  _prev: SignupActionState,
  formData: FormData
): Promise<SignupActionState> {
  // Parse and validate with 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;

  // Verify email with Mailbeam
  try {
    const verification = await mb.verify(email);

    if (!verification.valid || verification.score < 60) {
      return {
        success: false,
        errors: {
          email: [
            verification.reason === "disposable_domain"
              ? "Please use a permanent email address, not a temporary one."
              : "Please provide a valid, reachable email address.",
          ],
        },
      };
    }
  } catch {
    // Fail open — don't block signup on Mailbeam errors
    console.error("Mailbeam verification failed");
  }

  // Create the user
  try {
    await createUser({ email, password });
  } catch {
    return {
      success: false,
      errors: { general: "Something went wrong. Please try again." },
    };
  }

  redirect("/dashboard");
}

Step 4 — Build the form component

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

  // Reflect server errors into the form
  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"
        >
          Email address
        </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 ? "Creating account…" : "Create account"}
      </button>
    </form>
  );
}

Step 5 — Use it in your 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">Create your account</h1>
        <SignupForm />
      </div>
    </main>
  );
}

Testing

Use Mailbeam's deterministic test domains:

// In development/tests — these don't count against your 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"

To test the form locally:

  1. Start pnpm dev
  2. Navigate to /signup
  3. Submit throwaway@mailinator.com — should show inline email error
  4. Submit user@valid.mailbeam-test.dev — should redirect to /dashboard

Best practices

PracticeWhy
useActionState + useEffect for errorsServer errors reflected into form without page reload
Fail open on mb.verify errorsMailbeam downtime doesn't break signup
Use reason field for specific messages"Temporary email" is clearer than "Invalid email"
aria-invalid + aria-describedbyScreen reader accessible error states
Test domains in devNo quota usage during development

Production checklist

  • MAILBEAM_KEY set as an env secret in Vercel (or your platform)
  • Error messages are user-friendly and specific
  • aria-invalid and aria-describedby attributes set on inputs
  • Fail-open path tested with a mocked API error

Next steps