Le contact de mon portfolio reposait sur un lien mailto:. Ça semble raisonnable jusqu'au jour où on réalise que la moitié des gens sont sur un webmail : le clic ouvre un client mail vide qu'ils n'ont jamais configuré, et le message n'arrive jamais.
Les solutions habituelles (Formspree, Getform…) marchent, mais je m'étais fixé une règle sur ce site : posséder toute la chaîne. Voici ce que ça donne.
## 1. Le message va en base, pas dans les limbes
Une table Prisma de quatre champs — nom, email, message, lu — et une route POST /api/contact validée par zod. Le message est d'abord persisté. Tout le reste (notification, email) est du bonus : si ça échoue, rien n'est perdu, le message m'attend dans l'onglet « messages » de mon panneau d'admin.
## 2. Le honeypot qui ment aux bots
Un champ website invisible pour les humains (hors écran, tabindex=-1), irrésistible pour les bots qui remplissent tout. Le détail qui compte : quand il est rempli, je ne renvoie pas d'erreur — je réponds {"ok": true} et je n'enregistre rien.
// le bot croit avoir réussi ; il n'apprend rien
if (input.website) return ok({ ok: true }, { status: 201 });Renvoyer une 400 explicite, c'est donner au bot le mode d'emploi pour passer la prochaine fois.
## 3. Rate-limit minimal, suffisant
Cinq messages par heure et par IP, dans une simple Map en mémoire. Ça se réinitialise au redémarrage du serveur et ça ne survivrait pas à plusieurs instances — mais pour un portfolio sur un VPS avec un seul process, c'est exactement le bon niveau de complexité.
## 4. La notification qui n'ajoute aucune latence
L'email de notification part après la réponse HTTP, grâce à after() de Next :
after(() => sendContactNotification({ name, email, message }));Le visiteur a sa confirmation immédiatement ; le SMTP prend le temps qu'il veut. Et le champ replyTo de l'email pointe sur l'adresse du visiteur : dans ma boîte, « répondre » répond à la bonne personne.
## Ce que je retiens
Un formulaire de contact, c'est quatre problèmes distincts : persister, filtrer, limiter, notifier. Les traiter séparément rend chaque morceau trivial — et aucun ne dépend d'un service que je ne contrôle pas.
## Pour aller plus loin
- after() — docs Next.js
- Nodemailer, pour l'envoi SMTP
- Zod, pour la validation