Per validare un indirizzo email in Next.js devi rispondere a tre domande, e
l'App Router ti offre un punto preciso in cui rispondere a ciascuna. L'indirizzo
ha la forma corretta? Il suo dominio può ricevere posta? La casella esiste
davvero? La prima è uno schema zod condiviso tra un Client Component e il
server; la seconda è una ricerca DNS; la terza è un problema di rete da affidare
a un'API di verifica. La trappola è fermarsi alla prima domanda: un
z.string().email() superato nel browser è una comodità per la UX, non una
validazione, e non arriva mai integro al tuo database, perché chiunque può
aggirarlo con una POST diretta.
In breve#
Valida con uno schema zod condiviso per la sintassi, sia sul client sia sul
server, usa node:dns/promises per la ricerca MX dentro un Route Handler e
un'API di verifica per il controllo SMTP della casella: prima il più economico,
fermandoti appena un livello dà una risposta definitiva. Tieni sul server ogni
controllo che conta: la copia sul client serve solo per il feedback immediato, e
la chiave API che autentica il test della casella non deve mai arrivare al
browser. Non provare ad aprire connessioni SMTP da una funzione Next.js per
testare direttamente le caselle: la porta 25 in uscita è bloccata dalla maggior
parte degli host (e sul runtime Edge non è proprio disponibile), e la risposta
dipende dalla reputazione dell'IP di invio e dal greylisting, cose che non vuoi
reimplementare.
Livello 1: validazione del formato con uno schema zod condiviso#
Next.js non ha una primitiva propria per le email, ma il controllo
idiomatico del formato è uno schema zod, e zod funziona così bene qui perché lo stesso
schema gira nel browser e sul server. Definiscilo una volta sola:
// lib/email.ts
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Enter a valid email address."),
});
In un Client Component, esegui il parsing al momento dell'invio, così l'utente
vede subito l'errore senza un round-trip. safeParse restituisce un risultato
discriminato che puoi leggere senza try/catch:
"use client";
import { useState } from "react";
import { signupSchema } from "@/lib/email";
export function SignupForm() {
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const email = String(new FormData(e.currentTarget).get("email"));
// Instant feedback only — this is not a security boundary.
const parsed = signupSchema.safeParse({ email });
if (!parsed.success) {
setError(parsed.error.issues[0].message);
return;
}
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
setError(res.ok ? null : (await res.json()).error);
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" autoComplete="email" required />
{error && <p role="alert">{error}</p>}
<button type="submit">Sign up</button>
</form>
);
}
Il controllo sul client conviene tenerlo, perché evita una richiesta per
gli errori di battitura più evidenti, ma è tutt'altro che affidabile. Un utente
con la scheda Network aperta può inviare una POST direttamente a /api/signup e
saltarlo del tutto, quindi il server deve eseguire di nuovo lo stesso identico
schema prima di fidarsi di qualsiasi cosa.
Livello 2: il dominio può ricevere posta?#
Questo è il primo controllo che il browser non può fare e che zod non farà: un
dominio senza record MX non può accettare posta per nessuno, quindi una sola
ricerca DNS elimina domini morti, nomi di aziende scritti male e TLD inventati.
Un Route Handler di Next.js gira su Node per impostazione predefinita, quindi hai
node:dns/promises senza alcuna dipendenza:
// lib/email.ts (continued)
import { resolveMx } from "node:dns/promises";
export async function hasMailRoute(domain: string): Promise<boolean> {
try {
const mx = await resolveMx(domain);
return mx.length > 0;
} catch {
// ENOTFOUND / ENODATA → the domain doesn't exist or publishes no MX.
return false;
}
}
Superare questo controllo significa "il dominio accetta posta", non "questa casella esiste", ma un fallimento è definitivo e gratuito, esattamente ciò che vuoi da un livello economico.
Livello 3: la casella esiste davvero?#
I livelli 1 e 2 possono solo escludere un indirizzo. Un dominio può pubblicare
record MX perfetti e non avere comunque alcuna casella all'indirizzo che hai in
mano: noreply-9f2x@gmail.com ha una sintassi valida su un instradamento di posta
attivo, eppure è una casella mai creata. Confermare una casella specifica significa
passare per la conversazione SMTP di recapito: connettersi al server di posta,
inviare RCPT TO, leggere la risposta e disconnettersi prima di inviare qualsiasi
cosa.
Potresti gestirla con uno script in un runtime Node, ma non dovresti eseguirla da una funzione Next.js. La maggior parte degli host cloud blocca la porta 25 in uscita, la risposta dipende dalla reputazione dell'IP da cui ti connetti e i server riceventi applicano il greylisting e limitano la frequenza dei mittenti sconosciuti: così un test che funziona in locale fallisce senza avvisi in produzione, o ti fa finire in blacklist. Come funziona la verifica email illustra l'intera pipeline, domini catch-all compresi. Questo è il livello che conviene delegare.
L'endpoint di verifica di Qualisend esegue l'intera pipeline da un'infrastruttura
con reputazione gestita e restituisce un esito. Chiamalo lato server con fetch,
leggendo la chiave da una variabile d'ambiente, così non lascia mai la macchina:
// lib/email.ts (continued)
export async function verifyMailbox(email: string): Promise<string> {
const res = await fetch("https://app.qualisend.com/api/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(4000), // a slow call must not stall signup
}).catch(() => null); // timeout or network error: fail open too
// Don't block a real signup on our outage — treat a failure as "unknown".
if (!res?.ok) return "unknown";
const { result } = await res.json();
return result.status; // "deliverable" | "risky" | "undeliverable" | "unknown"
}
La risposta riporta l'esito in un contenitore result, accanto a un job_id.
Leggi result.status insieme a score, reason e sub_flags, che segnala
caratteristiche come gli indirizzi generici o usa e getta:
{
"job_id": "6f1c2e0a-9b3d-4a1e-8c77-1a2b3c4d5e6f",
"result": {
"status": "deliverable",
"score": 95,
"reason": "accepted_email",
"smtp": "cached",
"sub_flags": { "role": false, "disposable": false, "free_mail": false, "catch_all": false }
}
}
Quel result è l'esito immediato. Quando result.smtp è "cached" o "skipped",
l'esito è già definitivo. Finché è "pending", il test SMTP della casella è ancora in
corso: status resta unknown, reason resta null e l'esito definitivo arriva
su GET https://app.qualisend.com/api/v1/jobs/{job_id}. All'iscrizione, agisci in
base all'esito immediato; se vuoi quello definitivo, interroga quel job da
un'attività in background, mai dentro la richiesta e mai chiamando di nuovo
/verify.
Consulta il riferimento API per la struttura esatta dei campi;
all'helper qui sopra basta status per rifiutare un indirizzo che non verrebbe
recapitato. Nota che la variabile d'ambiente è semplicemente QUALISEND_API_KEY,
senza il prefisso NEXT_PUBLIC_: è quel prefisso a inserire un valore nel
bundle client, e una chiave di verifica nel browser è una chiave che chiunque può
leggere e consumare.
Combinare i livelli per validare un indirizzo email in Next.js#
Ora unisci i tre helper in un unico route.ts, partendo dal più economico, così
il credito API viene speso solo per un indirizzo che ha già superato il controllo della sintassi e
quello MX:
// app/api/signup/route.ts
import { NextResponse } from "next/server";
import { signupSchema, hasMailRoute, verifyMailbox } from "@/lib/email";
export async function POST(request: Request) {
// Layer 1 — re-validate on the server; the client check is not a boundary.
const parsed = signupSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: "Enter a valid email address." }, { status: 400 });
}
const { email } = parsed.data;
const domain = email.slice(email.lastIndexOf("@") + 1);
// Layer 2 — cheap MX gate, rules out dead domains before any API call.
if (!(await hasMailRoute(domain))) {
return NextResponse.json({ error: "That domain can't receive email." }, { status: 400 });
}
// Layer 3 — the SMTP mailbox probe, delegated to Qualisend.
if ((await verifyMailbox(email)) === "undeliverable") {
return NextResponse.json(
{ error: "We couldn't confirm a mailbox at this address." },
{ status: 400 },
);
}
// Every layer passed (or the probe was inconclusive) — create the account.
// await createUser(email);
return NextResponse.json({ ok: true });
}
Tutto il trucco sta in quest'ordine: lo schema ferma subito gli indirizzi senza
senso, il controllo DNS locale esclude i domini morti a costo zero e l'API viene
chiamata solo per gli indirizzi che hanno superato entrambi. L'handler blocca solo
undeliverable e lascia passare risky e unknown, quindi un indirizzo dubbio o
un errore temporaneo dell'API non respinge mai un cliente reale: puoi decidere più
avanti in base a score o a una voce di sub_flags, invece di bloccare la
registrazione.
Se preferisci inviare il modulo senza scrivere codice fetch sul client, lo stesso
identico corpo funziona in una Server Action. Restituisci { error } invece di
NextResponse e usalo con useActionState:
"use server";
import { signupSchema, hasMailRoute, verifyMailbox } from "@/lib/email";
export async function signup(_prev: unknown, formData: FormData) {
const parsed = signupSchema.safeParse({ email: formData.get("email") });
if (!parsed.success) return { error: "Enter a valid email address." };
const { email } = parsed.data;
const domain = email.slice(email.lastIndexOf("@") + 1);
if (!(await hasMailRoute(domain))) return { error: "That domain can't receive email." };
if ((await verifyMailbox(email)) === "undeliverable") {
return { error: "We couldn't confirm a mailbox at this address." };
}
return { ok: true };
}
È la stessa struttura a tre livelli che trovi nella guida per Node.js: a rendere affidabile la validazione è la stratificazione, non il framework. Esegui questa pipeline veloce in modo sincrono all'iscrizione e riserva i controlli batch più approfonditi alla pulizia delle liste; la guida alle iscrizioni serverless mostra il modello in tempo reale dall'inizio alla fine e il confronto tra API passa in rassegna i servizi che possono gestire il terzo livello.
Domande frequenti
01z.string().email() basta per validare un indirizzo email in Next.js?
Per la sintassi, sì: z.string().email() è il controllo giusto per il primo livello ed è molto meglio di una regex scritta a mano. Ma valida la forma: non risolve il DNS e non contatta alcun server di posta, quindi superarlo significa "sembra un'email", non "verrà recapitata". E un controllo solo nel browser si aggira con estrema facilità inviando una POST direttamente alla tua route. Esegui di nuovo lo schema sul server, poi aggiungi una ricerca MX e un controllo SMTP della casella prima di fidarti dell'indirizzo.
02Dove metto la chiave API di Qualisend in un'app Next.js?
In una variabile d'ambiente solo lato server chiamata QUALISEND_API_KEY, mai in una con il prefisso NEXT_PUBLIC_. Il prefisso NEXT_PUBLIC_ inserisce il valore nel bundle client, quindi una chiave di verifica con quel prefisso è leggibile da chiunque apra il tuo sito. Leggi process.env.QUALISEND_API_KEY dentro un Route Handler o una Server Action, dove resta sul server, e fai chiamare al browser il tuo endpoint invece di Qualisend direttamente.
03Devo validare un'email sul client o sul server in Next.js?
Su entrambi, ma conta solo il server. Esegui lo schema zod condiviso nel Client Component per un feedback immediato sugli errori di battitura, e di nuovo lo stesso identico schema nel Route Handler o nella Server Action, perché il controllo sul client si può saltare del tutto. I livelli DNS e SMTP sono per natura solo lato server, dato che richiedono le API di Node e una chiave segreta, quindi il browser non li vede mai.
04Posso eseguire il controllo DNS/MX sul runtime Edge?
No. node:dns/promises richiede il runtime Node.js, che i Route Handler usano per impostazione predefinita; una route con export const runtime = "edge" non ha il modulo dns. Se ti serve Edge, elimina il filtro MX locale e lascia che sia Qualisend a risolvere il DNS durante la chiamata di verifica: la sua pipeline esegue già la ricerca MX e il test SMTP, quindi l'unica chiamata API copre il secondo e il terzo livello.
Vuoi aggiungere il livello della casella? Inserisci un indirizzo sintatticamente
perfetto nella verifica email gratuita per vedere una stringa
approvata da zod tornare undeliverable, poi collega lo stesso esito al tuo Route
Handler con gli esempi pronti da copiare nel riferimento API.



