Recevoir un formulaire par e-mail6 min de lecture
Un formulaire de contact Next.js qui envoie un e-mail
Deux façons d'envoyer un formulaire Next.js par e-mail avec une Server Action : l'envoyer vous-même avec Resend ou Nodemailer, ou le transmettre à un backend.
Dans l'App Router, une Server Action reçoit le FormData du formulaire côté serveur, où une clé d'API d'e-mail est en sécurité. Vous pouvez envoyer l'e-mail vous-même avec Resend ou Nodemailer, ou transmettre l'envoi à un backend de formulaires qui filtre le spam, le stocke et vous envoie un e-mail. L'envoyer vous-même vous donne le contrôle total de l'e-mail ; le transmettre vous épargne le filtrage du spam, les limites de débit et le stockage que vous auriez sinon à construire.
Ce qui change ici
Next.js a déjà un serveur : la raison habituelle d'utiliser un backend de formulaires, « mon site ne peut pas exécuter de code », ne s'applique donc pas. La question est ce que ce code doit faire. Envoyer l'e-mail est la partie facile. C'est tout ce qu'il y a autour qui prend du temps :
- L'envoyer vous-même : l'action valide les champs, vérifie le honeypot et appelle votre fournisseur d'e-mail avec une clé d'API. Le message n'existe que sous forme de cet e-mail.
- Le transmettre : l'action envoie les champs à Ikiform avec votre clé secrète. Ikiform valide selon la définition du formulaire, évalue le spam, stocke la soumission et écrit aux destinataires.
| Tâche | Server Action + Resend ou Nodemailer | Server Action avec Ikiform |
|---|---|---|
| Validation | Zod dans l'action | La définition des champs du formulaire, appliquée côté Ikiform |
| Spam | Votre honeypot ; tout le reste est à ajouter vous-même | Honeypot, score de spam et vérification humaine facultative |
| Limites de débit | Aucune tant que vous n'ajoutez pas un stockage comme Redis | Intégrées, par visiteur et par formulaire |
| Domaine d'envoi | Vérifiez le vôtre avec des enregistrements SPF et DKIM | Aucun : les e-mails viennent de notifications@ikiform.com |
| Objet et design | Tout ce que vous voulez | Fixe : New submission: <form name> |
| Confirmation au visiteur | Un appel send de plus | Indisponible ; envoyez-la depuis votre outil d'e-mailing |
| Une copie de chaque message | Seulement si vous ajoutez une base de données | Tableau de bord, export CSV et API |
Choisissez la version maison pour un e-mail à votre marque ou une confirmation au visiteur, si vous acceptez de gérer le spam et le stockage. Choisissez la transmission pour ne plus penser au formulaire une fois qu'il fonctionne.
Les deux versions
Le formulaire est une demande de projet : nom, e-mail professionnel, entreprise, une fourchette de budget et une description. Le type d'état et le composant client sont partagés ; seul actions.ts change.
export interface InquiryState {
sent: boolean;
errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}
export const initialInquiry: InquiryState = { sent: false, errors: {} };"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { sendInquiry } from "./actions";
import { initialInquiry } from "./state";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Sending" : "Send inquiry"}</button>;
}
export function InquiryForm() {
const [state, action] = useActionState(sendInquiry, initialInquiry);
if (state.sent) return <p role="status">Thanks. We reply to every inquiry within two working days.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue="">
<option value="" disabled>Choose a range</option>
<option value="under-5k">Under $5k</option>
<option value="5k-20k">$5k to $20k</option>
<option value="over-20k">Over $20k</option>
</select>
{state.errors.budget ? <p role="alert">{state.errors.budget}</p> : null}
<label htmlFor="message">About the project</label>
<textarea id="message" name="message" rows={6} required minLength={20} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<SubmitButton />
</form>
);
}"use server";
import { Resend } from "resend";
import { z } from "zod";
import type { InquiryState } from "./state";
const resend = new Resend(process.env.RESEND_API_KEY);
const schema = z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
company: z.string().trim().max(120).optional(),
budget: z.enum(["under-5k", "5k-20k", "over-20k"], "Pick a budget range."),
message: z.string().trim().min(20, "A few sentences help us reply properly.").max(5000),
});
export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
if (formData.get("_gotcha")) return { sent: true, errors: {} };
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const fields = z.flattenError(parsed.error).fieldErrors;
return {
sent: false,
errors: Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages?.[0]])),
};
}
const inquiry = parsed.data;
const { error } = await resend.emails.send({
from: "Website <inquiries@yourdomain.com>",
to: ["you@yourdomain.com"],
replyTo: inquiry.email,
subject: `Project inquiry from ${inquiry.name}`,
text: [
`Name: ${inquiry.name}`,
`Email: ${inquiry.email}`,
`Company: ${inquiry.company ?? "-"}`,
`Budget: ${inquiry.budget}`,
"",
inquiry.message,
].join("\n"),
});
if (error) return { sent: false, errors: { form: "Couldn't send your inquiry. Try again." } };
return { sent: true, errors: {} };
}- Le type d'état a son propre fichier car un fichier
"use server"ne peut exporter que des fonctions async. useFormStatuslit l'état en attente du formulaire dans lequel il se trouve : le bouton a donc son propre composant.- Dans la version Resend, un honeypot rempli renvoie un succès sans rien envoyer, pour que le bot n'apprenne rien. Ikiform fait de même quand il stocke l'envoi comme spam.
replyTodans la version Resend, et le champ nomméemaildans la version Ikiform, font tous deux que Reply écrit au visiteur.- Nodemailer fonctionne comme Resend : créez un transport avec vos paramètres SMTP et appelez
sendMailavecfrom,to,replyTo,subjectettext. - Pour la version Ikiform, définissez les cinq champs dans l'onglet Fields du formulaire avec
budgetenselect, pour qu'une mauvaise valeur reçoive422 validation_failedavec un message par champ.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur. Chaque exemple serveur transmet l'en-tête Origin du navigateur quand il existe, car le fetch d'un serveur n'en envoie aucun et Ikiform s'en sert pour compter les soumissions de test gratuites depuis localhost.Il existe une troisième option : se passer de l'action et laisser le navigateur envoyer directement à Ikiform. Aucune clé secrète n'intervient, donc la limite de débit Standard et Allowed domains s'appliquent par visiteur. Le guide Next.js de la documentation montre le composant client.
Vérifiez que tout fonctionne
- Envoyez avec un budget vide. L'erreur s'affiche sous le select et rien d'autre n'est perdu.
- Envoyez une vraie demande. La phrase de succès remplace le formulaire.
- Ouvrez l'e-mail et cliquez sur Reply. La ligne To contient l'adresse que vous avez saisie.
- Remplissez le honeypot masqué avec les outils de développement du navigateur et envoyez. Vous voyez un succès, et aucun e-mail n'arrive.
- Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés. Cette étape ne concerne que la version Ikiform.
Pièges
- Échappement dans les e-mails HTML. La version Resend envoie
text. Si vous passez àhtml, échappez d'abord chaque valeur, sinon un visiteur peut glisser du markup et des liens dans votre boîte. - Injection d'en-têtes. Ne mettez jamais une valeur brute du formulaire dans
fromouto.replyToest sûr avec Resend car Zod a déjà vérifié qu'il s'agit d'un e-mail. - Variables d'environnement.
RESEND_API_KEYetIKIFORM_SECRET_KEYne doivent pas commencer parNEXT_PUBLIC_, sinon Next.js les envoie au navigateur. - Envois en double. Un double clic peut exécuter l'action deux fois. L'état en attente désactive le bouton, mais un réseau lent peut quand même provoquer une course ; l'option
idempotencyKeyde Resend, ou l'en-têteIdempotency-Keyd'Ikiform, fait d'une répétition une opération sans effet. - Les Server Actions sont des endpoints publics. N'importe qui peut en appeler une directement : c'est pourquoi la validation et les vérifications anti-spam doivent s'exécuter à l'intérieur, pas seulement dans le formulaire.
Si quelque chose ne va pas
| Problème | Que faire |
|---|---|
| Resend répond que le domaine n'est pas vérifié | Ajoutez les enregistrements DNS de Resend pour le domaine utilisé dans from, ou envoyez depuis l'adresse de test de Resend pendant le développement. |
429 rate_limited | Un visiteur a envoyé plus que ce que permet la limite de débit du formulaire. Standard autorise 5 envois par minute et par visiteur. Attendez la durée de Retry-After, ou choisissez Relaxed ou Custom pour les formulaires qui envoient depuis une seule IP partagée. |
422 validation_failed | L'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ. |
| L'action fonctionne en local mais pas en production | La clé manque dans les variables d'environnement du déploiement. Ajoutez-la à l'environnement de production et redéployez. |
Questions
Oui, dans le runtime Node.js, que les Server Actions utilisent par défaut. Il ouvre une connexion SMTP : il ne tourne donc pas sur le runtime Edge, et certains hébergeurs limitent le SMTP sortant. Une API d'e-mail HTTP comme Resend évite les deux problèmes.
Traitez le formulaire avec une Server Action, qui s'exécute côté serveur. Appelez-y une API d'e-mail comme Resend ou Nodemailer, ou transmettez les champs à un backend de formulaires qui vous envoie un e-mail.
Oui, la clé d'API reste sur le serveur tant que son nom ne commence pas par NEXT_PUBLIC_. Validez chaque champ dans l'action, car les Server Actions peuvent être appelées directement.
Pas pour envoyer un e-mail. Il en faut une si vous voulez une trace des messages qui survive à un e-mail perdu. Un backend de formulaires conserve cette trace pour vous.
Non. Ikiform n'écrit qu'à vous et à votre équipe. Envoyez une confirmation depuis votre propre fournisseur d'e-mail, dans votre Server Action ou depuis un webhook.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.