Email Verification with Clerk
Clerk supports webhooks for user lifecycle events including user.created. While Clerk doesn't have a native pre-creation block, you can combine a webhook with Clerk's user metadata to mark invalid signups for immediate deletion, or use Clerk's before-signup redirect pattern.
This tutorial uses the recommended pattern: verify the email via a server endpoint before calling Clerk's signUp.create.
Prerequisites
- Clerk account + Next.js project with
@clerk/nextjs - A Mailbeam API key (sign up free)
Setup
npm install @mailbeam/sdk svix # svix is Clerk's webhook libraryServer-side verification endpoint
// app/api/verify-email/route.ts
import { NextResponse } from "next/server";
import Mailbeam from "@mailbeam/sdk";
const mb = new Mailbeam({ apiKey: process.env.MAILBEAM_KEY! });
export async function POST(req: Request) {
const { email } = await req.json();
if (!email) {
return NextResponse.json({ error: "email required" }, { status: 400 });
}
try {
const { valid, score, reason } = await mb.verify(email);
return NextResponse.json({ valid, score, reason });
} catch {
// Fail open
return NextResponse.json({ valid: true, score: 50, reason: null });
}
}Custom signup form
// app/signup/CustomSignupForm.tsx
"use client";
import { useSignUp } from "@clerk/nextjs";
import { useState } from "react";
export function CustomSignupForm() {
const { signUp, setActive } = useSignUp();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
// Step 1: Verify email server-side before calling Clerk
const verifyRes = await fetch("/api/verify-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const { valid, score, reason } = await verifyRes.json();
if (!valid || score < 60) {
setError(
reason === "disposable_domain"
? "Please use a permanent email address."
: "Please provide a valid email address."
);
setLoading(false);
return;
}
// Step 2: Proceed with Clerk signup
try {
const result = await signUp!.create({ emailAddress: email, password });
if (result.status === "complete") {
await setActive!({ session: result.createdSessionId });
window.location.href = "/dashboard";
}
} catch (err: unknown) {
setError((err as { errors?: { message: string }[] })?.errors?.[0]?.message ?? "Signup failed.");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-sm">
{error && (
<p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{error}
</p>
)}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email address"
className="w-full rounded-lg border border-border px-3 py-2 text-sm bg-background"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full rounded-lg border border-border px-3 py-2 text-sm bg-background"
required
/>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-60"
>
{loading ? "Creating account…" : "Create account"}
</button>
</form>
);
}Best practices
- Verify before calling Clerk, not after — prevents orphaned unverified users
- Keep the verify endpoint lightweight — it should only call Mailbeam and return
- Use
reasonto give users specific error messages