# 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.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/send-form-to-email/nextjs

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 :

Schéma : Ce que fait une Server Action quand elle envoie l'e-mail, et quand elle transmet l'envoi

1. 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.
2. 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.

Les deux gardent toutes les clés sur le serveur. La différence, c'est la part du bloc central que vous écrivez et maintenez.

| 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.

`app/inquiry/state.ts`:

```ts
export interface InquiryState {
  sent: boolean;
  errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}

export const initialInquiry: InquiryState = { sent: false, errors: {} };
```

`app/inquiry/inquiry-form.tsx`:

```tsx
"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>
  );
}
```

**Resend** (`app/inquiry/actions.ts`)

```ts
"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: {} };
}
```

**Ikiform** (`app/inquiry/actions.ts`)

```ts
"use server";

import { headers } from "next/headers";
import type { InquiryState } from "./state";

export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
  const origin = (await headers()).get("origin");
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      name: formData.get("name"),
      email: formData.get("email"),
      company: formData.get("company"),
      budget: formData.get("budget"),
      message: formData.get("message"),
      _gotcha: formData.get("_gotcha") ?? "",
    }),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { sent: true, errors: {} };

  const errors: InquiryState["errors"] = {};
  for (const [field, messages] of Object.entries<string[]>(result?.error?.fields ?? {})) {
    errors[field as keyof InquiryState["errors"]] = messages[0];
  }
  if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your inquiry. Try again.";
  return { sent: false, errors };
}
```

- Le type d'état a son propre fichier car un fichier `"use server"` ne peut exporter que des fonctions async.
- `useFormStatus` lit 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.
- `replyTo` dans la version Resend, et le champ nommé `email` dans 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 `sendMail` avec `from`, `to`, `replyTo`, `subject` et `text`.
- Pour la version Ikiform, définissez les cinq champs dans l'onglet **Fields** du formulaire avec `budget` en `select`, pour qu'une mauvaise valeur reçoive `422 validation_failed` avec un message par champ.

> Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore **Allowed domains** et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la [limite de débit](https://ikiform.com/fr/docs/rate-limits.md) Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez `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](https://ikiform.com/fr/docs/guides/nextjs.md) montre le composant client.

## Vérifiez que tout fonctionne

1. Envoyez avec un budget vide. L'erreur s'affiche sous le select et rien d'autre n'est perdu.
2. Envoyez une vraie demande. La phrase de succès remplace le formulaire.
3. Ouvrez l'e-mail et cliquez sur **Reply**. La ligne **To** contient l'adresse que vous avez saisie.
4. 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.
5. 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 `from` ou `to`. `replyTo` est sûr avec Resend car Zod a déjà vérifié qu'il s'agit d'un e-mail.
- **Variables d'environnement.** `RESEND_API_KEY` et `IKIFORM_SECRET_KEY` ne doivent pas commencer par `NEXT_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 `idempotencyKey` de Resend, ou l'en-tête `Idempotency-Key` d'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

### Nodemailer fonctionne-t-il dans une Server Action ?

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.

### Comment envoyer un e-mail depuis un formulaire Next.js ?

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.

### Est-il sûr d'appeler Resend depuis une Server Action ?

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.

### Faut-il une base de données pour un formulaire de contact Next.js ?

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.

### Ikiform peut-il envoyer un e-mail de confirmation au visiteur ?

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.

## Autres guides

- [Recevoir un formulaire par e-mail](https://ikiform.com/fr/guides/send-form-to-email.md): Recevez chaque soumission dans votre boîte sans serveur mail : pourquoi mailto : et mail() de PHP échouent, et une solution qui filtre le spam et permet de répondre.
- [Alternative à EmailJS](https://ikiform.com/fr/guides/send-form-to-email/emailjs-alternative.md): Quittez EmailJS pour un formulaire de contact : pourquoi les clés côté navigateur sont un risque, ce qui change dans le code, et ce que vous perdez, comme la réponse automatique.
- [Plusieurs destinataires](https://ikiform.com/fr/guides/send-form-to-email/multiple-recipients.md): Envoyez un formulaire à toute votre équipe, ou aiguillez chaque message vers la bonne personne selon le sujet, sans laisser le formulaire choisir qui reçoit l'e-mail.
- [Répondre depuis votre boîte](https://ikiform.com/fr/guides/send-form-to-email/reply-to.md): Faites que Répondre sur une notification écrive à la personne qui a rempli le formulaire : noms de champs valides, quand utiliser _replyto, et pourquoi les réponses s'égarent.
- [Sans PHP](https://ikiform.com/fr/guides/send-form-to-email/without-php.md): Remplacez un script contact.php qui appelle mail() par un formulaire qui envoie vers un endpoint hébergé, en gardant vos noms de champs, votre page de remerciement et les réponses.
- [Vers Gmail](https://ikiform.com/fr/guides/send-form-to-email/gmail.md): Recevez les soumissions dans Gmail sans liens mailto:, mots de passe d'application ni Apps Script, puis libellez-les, évitez les spams et répondez depuis Gmail.
