Pour valider une adresse e-mail dans Next.js, vous devez répondre à trois
questions, et l'App Router vous offre un endroit propre pour répondre à chacune.
L'adresse est-elle correctement formée ? Son domaine peut-il recevoir du courrier ?
La boîte aux lettres existe-t-elle réellement ? La première est un schéma zod
que vous partagez entre un Client Component et le serveur ; la deuxième est une
résolution DNS ; la troisième est un problème réseau que vous confiez à une API
de vérification. Le piège consiste à s'arrêter à la première question — un contrôle
z.string().email() réussi dans le navigateur est un confort d'UX, pas une
validation, et il n'atteint jamais votre base de données intact, car n'importe qui
peut le court-circuiter en envoyant directement une requête POST.
La réponse courte#
Validez avec un schéma zod partagé pour la syntaxe, à la fois côté client et
côté serveur, node:dns/promises pour la résolution MX à l'intérieur d'un Route
Handler, et une API de vérification pour le contrôle SMTP de la boîte aux lettres —
la moins coûteuse d'abord, en court-circuitant dès qu'une couche est décisive.
Gardez sur le serveur chaque contrôle qui compte : la copie côté client sert au
retour instantané, et la clé d'API qui authentifie le test de la boîte aux lettres
ne doit jamais parvenir au navigateur. N'essayez pas d'ouvrir vous-même des
connexions SMTP depuis une fonction Next.js pour tester les boîtes aux lettres —
le port 25 sortant est bloqué sur la plupart des hébergeurs (et totalement
indisponible sur le runtime Edge), et la réponse dépend de la réputation de l'IP
d'envoi et du greylisting que vous ne voulez pas réimplémenter.
Couche 1 : validation du format avec un schéma zod partagé#
Next.js n'a pas de primitive e-mail propre, mais le contrôle de format idiomatique
est un schéma zod, et la raison pour laquelle zod s'y prête si bien, c'est que
le même schéma s'exécute dans le navigateur et sur le serveur. Définissez-le une
seule fois :
// lib/email.ts
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Enter a valid email address."),
});
Dans un Client Component, analysez la valeur avec ce schéma à la soumission, pour
que l'utilisateur reçoive une erreur instantanée sans aller-retour. safeParse
renvoie un résultat discriminé que vous pouvez lire sans 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>
);
}
Le contrôle côté client vaut la peine — il évite une requête sur les fautes de
frappe évidentes — mais il est tout sauf digne de confiance. Un utilisateur qui a
l'onglet réseau ouvert peut envoyer une requête POST directement à /api/signup
et le contourner entièrement, si bien que le serveur doit réexécuter exactement le
même schéma avant de croire quoi que ce soit.
Couche 2 : le domaine peut-il recevoir du courrier ?#
C'est le premier contrôle que le navigateur ne peut pas faire et que zod ne fera
pas : un domaine sans enregistrement MX ne peut accepter de courrier pour personne,
si bien qu'une seule résolution DNS élimine les domaines morts, les noms
d'entreprise mal orthographiés et les TLD inventés. Un Route Handler Next.js
s'exécute par défaut sur Node, vous disposez donc de node:dns/promises sans la
moindre dépendance :
// 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;
}
}
Un contrôle réussi ici signifie « le domaine accepte le courrier », pas « cette boîte aux lettres existe » — mais un échec est décisif et gratuit, ce qui est exactement ce que l'on attend d'une couche bon marché.
Couche 3 : la boîte aux lettres existe-t-elle réellement ?#
Les couches 1 et 2 ne peuvent qu'écarter une adresse. Un domaine peut publier
des enregistrements MX parfaits et n'avoir malgré tout aucune boîte aux lettres à
l'adresse que vous détenez — noreply-9f2x@gmail.com est une syntaxe valide sur
une route de messagerie active, et c'est pourtant une boîte aux lettres qui n'a
jamais été créée. Confirmer une boîte aux lettres précise passe par la conversation
de livraison SMTP : se connecter au serveur de messagerie, émettre RCPT TO, lire
la réponse et se déconnecter avant d'envoyer quoi que ce soit.
Vous pourriez scripter cela depuis un runtime Node, mais vous ne devriez pas l'exécuter depuis une fonction Next.js. La plupart des hébergeurs cloud bloquent le port 25 sortant, la réponse dépend de la réputation de l'IP depuis laquelle vous vous connectez, et les serveurs destinataires font du greylisting et limitent le débit des expéditeurs inconnus — si bien qu'un test qui fonctionne en local échoue silencieusement, ou vous fait blacklister, en production. Comment fonctionne la vérification d'e-mail parcourt le pipeline complet, domaines catch-all compris. C'est la couche qu'il vaut la peine de déléguer.
Le point de terminaison de vérification de Qualisend exécute tout le pipeline
depuis une infrastructure à réputation gérée et renvoie un verdict. Appelez-le côté
serveur avec fetch, en lisant la clé depuis une variable d'environnement pour
qu'elle ne quitte jamais la machine :
// lib/email.ts (continued)
export async function verifyMailbox(email: string): Promise<string> {
const res = await fetch("https://api.qualisend.com/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
// 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 réponse est une enveloppe { "result": { ... } }. Vous lisez result.status
aux côtés d'un score, d'un reason et de sub_flags pour des traits comme les
adresses de rôle ou jetables :
{
"result": {
"status": "deliverable",
"score": 95,
"reason": null,
"sub_flags": { "role": false, "disposable": false, "free": false, "catch_all": false }
}
}
Consultez la référence de l'API pour la forme exacte des champs ;
l'assistant ci-dessus n'a besoin que de status pour rejeter une adresse qui ne
sera pas délivrée. Notez que la variable d'environnement est simplement
QUALISEND_API_KEY, et non préfixée par NEXT_PUBLIC_ — c'est ce préfixe qui
intègre une valeur dans le bundle client, et une clé de vérification dans le
navigateur est une clé que n'importe qui peut lire et dépenser.
Composer les couches pour valider une adresse e-mail dans Next.js#
Assemblez maintenant les trois assistants dans un seul route.ts, le moins coûteux
d'abord, pour que le crédit d'API ne soit dépensé que sur une adresse qui a déjà
passé la syntaxe et le contrôle 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 });
}
Cet ordonnancement est toute l'astuce : le schéma court-circuite les valeurs
farfelues, le contrôle DNS local écarte gratuitement les domaines morts, et l'API
n'est sollicitée que pour les adresses qui ont franchi les deux premières couches.
Le handler ne bloque durement que undeliverable et laisse passer risky et
unknown, si bien qu'une adresse limite ou une défaillance passagère de l'API ne
refoule jamais un vrai client — vous pouvez brancher sur score ou sur une entrée
de sub_flags plus loin dans le flux plutôt que de bloquer l'inscription.
Si vous préférez soumettre le formulaire sans écrire de code de fetch côté client,
le corps identique fonctionne dans une Server Action. Renvoyez { error } au lieu
de NextResponse et consommez-le avec 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 };
}
C'est la même structure à trois couches que vous retrouverez dans le guide Node.js — c'est le découpage en couches, pas le framework, qui rend la validation fiable. Exécutez ce pipeline rapide de façon synchrone à l'inscription et réservez les vérifications par lots plus poussées au nettoyage de listes ; le guide de l'inscription serverless montre le schéma en temps réel de bout en bout, et la comparaison d'API aligne les services capables de soutenir la troisième couche.
Foire aux questions#
z.string().email() suffit-il pour valider une adresse e-mail dans Next.js ?#
Pour la syntaxe, oui — z.string().email() est le bon contrôle de première couche
et bien supérieur à une regex maison. Mais il valide la forme : il ne résout
jamais le DNS et ne contacte jamais un serveur de messagerie, si bien qu'un
contrôle réussi signifie « ressemble à un e-mail », pas « sera délivré ». Et un
contrôle uniquement côté navigateur se contourne trivialement en envoyant une
requête directement à votre route. Réexécutez le schéma sur le serveur, puis
ajoutez une résolution MX et un test SMTP de boîte aux lettres avant de faire
confiance à l'adresse.
Où placer la clé d'API Qualisend dans une application Next.js ?#
Dans une variable d'environnement réservée au serveur, nommée QUALISEND_API_KEY
— jamais dans une variable préfixée par NEXT_PUBLIC_. Le préfixe NEXT_PUBLIC_
intègre une valeur dans le bundle client, si bien qu'une clé de vérification
portant ce préfixe est lisible par toute personne qui ouvre votre site. Lisez
process.env.QUALISEND_API_KEY à l'intérieur d'un Route Handler ou d'une Server
Action, où elle reste sur le serveur, et laissez le navigateur appeler votre propre
point de terminaison plutôt que Qualisend directement.
Faut-il valider un e-mail côté client ou côté serveur dans Next.js ?#
Les deux, mais seul le serveur fait foi. Exécutez le schéma zod partagé dans le
Client Component pour un retour instantané sur les fautes de frappe, et réexécutez
exactement le même schéma dans le Route Handler ou la Server Action, car le
contrôle client peut être entièrement contourné. Les couches DNS et SMTP sont par
nature réservées au serveur — elles nécessitent des API Node et une clé secrète —
de sorte que le navigateur ne les voit jamais.
Puis-je exécuter la vérification DNS/MX sur le runtime Edge ?#
Non. node:dns/promises nécessite le runtime Node.js, celui qu'utilisent les Route
Handlers par défaut ; une route avec export const runtime = "edge" n'a pas de
module dns. Si vous avez besoin d'Edge, retirez le filtre MX local et laissez
Qualisend résoudre le DNS dans le cadre de l'appel de vérification — son pipeline
effectue déjà la résolution MX et le test SMTP, si bien que l'unique appel d'API
couvre les couches deux et trois.
Prêt à ajouter la couche boîte aux lettres ? Déposez une adresse syntaxiquement
parfaite dans le vérificateur d'e-mail gratuit pour voir une
chaîne approuvée par zod revenir undeliverable, puis reliez le même verdict à
votre Route Handler grâce aux exemples prêts à copier-coller de la
référence de l'API.