Checks the address inside the signup server action, before you create the user, and shows the problem under the field.
The code
1 Keep the key on the server
MAILVET_KEY=mv_live_your_keySet it wherever your server reads its environment. Never put it in client code.
2 Put the check in one place
lib/mailvet.ts
// The fields this code reads. The response has more: mailvet.dev/docs
export type Check = {
verdict: "valid" | "risky" | "invalid";
disposable: boolean;
did_you_mean: string;
};
// null means no answer: MailVet is down or slow, the quota is
// used up, or the key is wrong. Callers let the signup through.
export async function checkEmail(email: string): Promise<Check | null> {
try {
const res = await fetch(
"https://mailvet.dev/v1/check?email=" + encodeURIComponent(email),
{
headers: { Authorization: `Bearer ${process.env.MAILVET_KEY}` },
signal: AbortSignal.timeout(5000),
},
);
if (!res.ok) {
console.error(`mailvet: HTTP ${res.status}`);
return null;
}
return (await res.json()) as Check;
} catch (err) {
console.error("mailvet: no answer", err);
return null;
}
}3 Call it from the action
app/actions.ts
"use server";
import { redirect } from "next/navigation";
import { checkEmail } from "@/lib/mailvet";
export type SignupState = { error: string };
export async function signup(
_prev: SignupState,
formData: FormData,
): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim();
if (!email) return { error: "Enter your email." };
const check = await checkEmail(email);
if (check?.did_you_mean) {
const local = email.slice(0, email.lastIndexOf("@"));
return { error: `Did you mean ${local}@${check.did_you_mean}?` };
}
if (check?.verdict === "invalid") {
return { error: "That address can't receive mail." };
}
if (check?.verdict === "risky" && check?.disposable) {
return { error: "Please use a permanent address, not a throwaway one." };
}
// Create the user here.
redirect("/welcome");
}4 Show the answer in the form
app/signup-form.tsx
"use client";
import { useActionState } from "react";
import { signup } from "./actions";
export function SignupForm() {
const [state, action, pending] = useActionState(signup, { error: "" });
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<p aria-live="polite">{state.error}</p>
<button disabled={pending}>Sign up</button>
</form>
);
}What to do with the answer
The action returns a message for did_you_mean, for invalid and for disposable. Anything else goes on to create the user, so a role address like info@ or a risky from a DNS failure still signs up.
- valid Let it through.
- risky Read
disposable. If it’s true, reject. If false, it’s a role address or DNS couldn’t be reached, so let it through. - invalid Bad format, or the domain has no mail server. Reject.
did_you_mean is empty or holds a domain, such as gmail.com for gamil.com. It doesn’t change the verdict. A known typo can come back valid because squatters give those domains a mail server, so check it first. See the docs for every field.
If MailVet is down, the signup goes through
checkEmail catches timeouts, network errors and non-200 answers, logs them and returns null. The ?. checks then all miss, and the signup carries on.
A 401 or a 429 takes the same path, so a bad key or a used-up quota means addresses pass unchecked. Watch your logs for it.
Watch out
- Server actions can be called with a direct POST. Next.js says so itself. Put your rate limit inside the action, not in the form.
- Each submit spends one check. Only run this at signup, not on every profile save.
Sources
Compared with these on November 5, 2026. If they’ve changed since, trust them over this page.