# Créer un formulaire d'inscription à la newsletter avec Next.js

> Un formulaire de newsletter en pied de page Next.js : une server action avec vérification du consentement et Turnstile, qui envoie à Ikiform, plus un route handler qui ajoute les lecteurs à Kit.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/newsletter-signup/nextjs

Placez un formulaire client dans le pied de page du layout racine et reliez-le à une server action. L'action vérifie la case de consentement et un token Cloudflare Turnstile, puis transmet l'inscription à votre backend de formulaires avec une clé secrète. Un route handler séparé reçoit le webhook signé du backend et ajoute le lecteur à votre outil d'e-mailing : le spam n'atteint donc jamais votre liste.

L'[onglet Next.js du guide principal](https://ikiform.com/fr/guides/newsletter-signup.md#form) contient une server action et un composant de formulaire. Cette page construit la version complète pour un vrai site : un formulaire dans le pied de page de chaque page, une vérification humaine qui fonctionne même quand la clé secrète contourne celle d'Ikiform, et un route handler qui transmet à Kit les inscriptions validées. La configuration du produit et les clés sont dans le [guide Next.js de la documentation](https://ikiform.com/fr/docs/guides/nextjs.md).

## Ce que change un formulaire de pied de page dans Next.js

Un formulaire de pied de page vit dans le layout racine : il s'affiche donc sur chaque page, y compris celles que Next.js construit une seule fois au déploiement. Trois conséquences en découlent :

| Point d'attention | Ce qui se passe | Ce que fait cette version |
| --- | --- | --- |
| Pages statiques | Le formulaire ne doit lire ni cookies ni en-têtes pendant le rendu, sinon chaque page devient dynamique | Le formulaire ne lit rien au rendu. Seule l'action touche à la requête |
| La vérification humaine | Un envoi avec clé secrète contourne la vérification Turnstile d'Ikiform | Effectue sa propre vérification Turnstile dans l'action |
| Qui peut l'appeler | Une server action est un endpoint POST public, joignable sans votre formulaire | Valide le consentement et le token avant tout envoi |

Schéma : Comment une inscription depuis le pied de page Next.js atteint Ikiform puis Kit

1. Le lecteur remplit le formulaire du pied de page. Il envoie à la server action de la page qu'il est en train de lire.
2. L'action vérifie la case de consentement, puis vérifie le token Turnstile auprès de Cloudflare.
3. Si l'une des vérifications échoue, le formulaire revient avec le message et l'e-mail saisi. Rien n'est envoyé.
4. Sinon, l'action envoie l'inscription à Ikiform avec la clé secrète et l'Origin du navigateur.
5. La réponse d'Ikiform devient l'état de l'action, et le formulaire est remplacé par la ligne invitant à vérifier sa boîte.
6. Séparément, Ikiform envoie un webhook signé pour chaque inscription qui n'est pas du spam. Le route handler le vérifie et ajoute le lecteur à un formulaire Kit, dont l'incentive email sert de confirmation Kit. Testez-le une fois avec une adresse à vous ; le [guide Kit](https://ikiform.com/fr/guides/newsletter-signup/kit.md) explique pourquoi.

L'action n'appelle jamais Kit elle-même. Ikiform répond au spam avec le même succès qu'à une vraie inscription : seul le webhook sait quelles inscriptions sont réelles.

## Mise en place du projet

`Arborescence du projet`:

```text
app/
  layout.tsx
  newsletter/
    actions.ts
    newsletter-form.tsx
    submit-button.tsx
    page.tsx
  api/ikiform/route.ts
lib/
  turnstile.ts
.env.local
```

Créez une clé secrète sous **API keys** avec le scope `submissions:write`. Créez un widget Turnstile dans le tableau de bord Cloudflare pour votre domaine et `localhost`. Seule la clé de site peut porter le préfixe `NEXT_PUBLIC_` ; les autres valeurs doivent rester réservées au serveur.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your_turnstile_site_key
TURNSTILE_SECRET_KEY=your_turnstile_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
KIT_API_KEY=your_kit_v4_api_key
KIT_FORM_ID=1234567
```

Collez la [définition des champs du guide principal](https://ikiform.com/fr/guides/newsletter-signup.md#fields) dans l'onglet **Fields** du formulaire, pour qu'Ikiform impose la case `consent` obligatoire, quoi qu'il reçoive.

## La construire

### La vérification Turnstile

L'endpoint siteverify de Cloudflare prend le secret, le token et, en option, l'IP du visiteur. Un token est valable 300 secondes et ne peut être vérifié qu'une fois.

`lib/turnstile.ts`:

```ts
import "server-only";

const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";

export async function passesTurnstile(token: string, visitor?: string) {
  if (!token) return false;
  const body = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET_KEY ?? "", response: token });
  if (visitor) body.set("remoteip", visitor);
  const response = await fetch(SITEVERIFY, { method: "POST", body }).catch(() => null);
  const outcome = (await response?.json().catch(() => null)) as { success?: boolean } | null;
  return outcome?.success === true;
}
```

### La server action

`app/newsletter/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { passesTurnstile } from "@/lib/turnstile";

export interface SignupState {
  status: "idle" | "subscribed" | "error";
  field?: "email" | "consent" | "check";
  message: string;
  email: string;
  firstName: string;
}

const PLACEMENTS = ["footer", "post", "newsletter-page"];

export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
  const email = String(formData.get("email") ?? "").trim();
  const firstName = String(formData.get("first_name") ?? "").trim().slice(0, 80);
  const kept = { email, firstName };

  if (formData.get("consent") !== "true") {
    return { ...kept, status: "error", field: "consent", message: "Tick the box so we know you want the emails." };
  }

  const request = await headers();
  const visitor = request.get("x-forwarded-for")?.split(",")[0]?.trim();
  if (!(await passesTurnstile(String(formData.get("cf-turnstile-response") ?? ""), visitor))) {
    return { ...kept, status: "error", field: "check", message: "The human check didn't pass. Try once more." };
  }

  const source = String(formData.get("source") ?? "");
  let response: Response;
  try {
    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: request.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        first_name: firstName || undefined,
        consent: true,
        source: PLACEMENTS.includes(source) ? source : "footer",
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { ...kept, status: "error", message: "We couldn't reach the list. Try again in a moment." };
  }

  const result = await response.json().catch(() => null);
  if (result?.ok) {
    return { status: "subscribed", message: "Almost done. Open the email we sent you and confirm your address.", email: "", firstName: "" };
  }
  const emailError = result?.error?.fields?.email?.[0];
  if (emailError) return { ...kept, status: "error", field: "email", message: emailError };
  return { ...kept, status: "error", message: result?.error?.message ?? "Couldn't subscribe. Try again." };
}
```

- Le consentement est vérifié en premier, car cela ne coûte rien. L'appel Turnstile vient ensuite.
- `source` est comparé à une liste fixe. Un envoi forgé ne peut pas remplir votre rapport d'emplacements de déchets.
- La ligne `Origin` transmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuis `localhost`, et le `fetch` d'un serveur n'en envoie aucun.
- Chaque erreur renvoie l'e-mail et le nom saisis. React réinitialise un formulaire après une action, et `defaultValue` les remet en place.
- Le honeypot est transmis tel quel. Ikiform stocke un honeypot rempli comme spam et répond par un succès : un bot n'apprend donc rien.

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

### Le formulaire et son état d'attente

`app/newsletter/submit-button.tsx`:

```tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}
```

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

```tsx
"use client";

import Script from "next/script";
import { useActionState, useEffect, useId, useRef } from "react";
import { type SignupState, subscribe } from "./actions";
import { SubmitButton } from "./submit-button";

declare global {
  interface Window {
    turnstile?: { render(box: HTMLElement, options: { sitekey: string }): string; reset(id: string): void };
  }
}

const initial: SignupState = { status: "idle", message: "", email: "", firstName: "" };

export function NewsletterForm({ source }: { source: "footer" | "post" | "newsletter-page" }) {
  const [state, action] = useActionState(subscribe, initial);
  const id = useId();
  const box = useRef<HTMLDivElement>(null);
  const widget = useRef<string | null>(null);

  function mountCheck() {
    if (!box.current || !window.turnstile || widget.current) return;
    widget.current = window.turnstile.render(box.current, {
      sitekey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "",
    });
  }

  useEffect(mountCheck, []);
  useEffect(() => {
    if (state.status === "error" && widget.current) window.turnstile?.reset(widget.current);
  }, [state]);

  if (state.status === "subscribed") return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <Script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" onLoad={mountCheck} />
      <label htmlFor={`${id}-email`}>Email</label>
      <input
        id={`${id}-email`}
        name="email"
        type="email"
        required
        autoComplete="email"
        defaultValue={state.email}
        aria-invalid={state.field === "email"}
      />
      <label htmlFor={`${id}-name`}>First name (optional)</label>
      <input id={`${id}-name`} name="first_name" maxLength={80} autoComplete="given-name" defaultValue={state.firstName} />
      <label>
        <input name="consent" type="checkbox" value="true" required aria-invalid={state.field === "consent"} />
        Email me the newsletter. I can unsubscribe at any time.
      </label>
      <input type="hidden" name="source" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <div ref={box} />
      {state.status === "error" ? <p role="alert">{state.message}</p> : null}
      <SubmitButton />
    </form>
  );
}
```

- `useFormStatus` doit être lu dans un enfant du `<form>`, c'est pourquoi le bouton est un composant à part.
- `useId` garde les id d'input uniques quand le formulaire du pied de page et celui d'une page `/newsletter` sont affichés ensemble.
- Le script Turnstile se charge avec `?render=explicit`. `render` place le widget dans le formulaire, qui envoie alors un input masqué `cf-turnstile-response`. Le rendu explicite fonctionne aussi pour les formulaires montés après une navigation côté client.
- Un token ne peut être vérifié qu'une fois : le widget est donc réinitialisé après chaque erreur. Sans cette réinitialisation, la seconde tentative échoue toujours.

### Le placer dans le layout

`app/layout.tsx`:

```tsx
import { NewsletterForm } from "./newsletter/newsletter-form";

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body>
        {children}
        <footer>
          <p>One email a month about what we shipped. No tracking pixels.</p>
          <NewsletterForm source="footer" />
        </footer>
      </body>
    </html>
  );
}
```

Utilisez `source="post"` à la fin des articles et `source="newsletter-page"` sur une page dédiée. `LayoutProps` est le type, généré par Next.js pour les layouts, qui connaît les routes.

## Transmettre les abonnés à Kit

Le route handler ci-dessous reçoit le webhook d'Ikiform. Il lit d'abord le corps comme du texte, car la signature couvre les octets exacts. Ajoutez son URL sous **Webhooks**, choisissez le formulaire de newsletter et copiez le secret `whsec_`. Le [guide Kit](https://ikiform.com/fr/guides/newsletter-signup/kit.md) couvre les tags et la signification de chaque réponse de Kit.

`app/api/ikiform/route.ts`:

```ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

function kit(path: string, body: Record<string, unknown>) {
  return fetch(`https://api.kit.com/v4${path}`, {
    method: "POST",
    headers: {
      "X-Kit-Api-Key": process.env.KIT_API_KEY ?? "",
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(body),
  });
}

export async function POST(request: Request) {
  let event;
  try {
    event = await verifyWebhook({
      payload: await request.text(),
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET ?? "",
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const created = await kit("/subscribers", {
    email_address: email,
    first_name: typeof first_name === "string" ? first_name : null,
    state: "inactive",
  });
  const added = created.ok
    ? await kit(`/forms/${process.env.KIT_FORM_ID}/subscribers`, { email_address: email })
    : created;

  if (added.status === 429) return new Response("Kit rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!added.ok) return new Response(`Kit answered ${added.status}`, { status: 502 });
  return new Response(null, { status: 204 });
}
```

- Si vous avez un `proxy.ts`, anciennement `middleware.ts`, qui redirige les visiteurs sans session, gardez `/api/ikiform` hors de son `matcher`. Sinon, chaque livraison reçoit votre redirection de connexion au lieu du handler.
- Avec `trailingSlash: true` dans `next.config`, Next.js redirige `/api/ikiform` vers `/api/ikiform/`. Enregistrez l'URL avec la barre oblique.
- Un `503` avec `Retry-After` fait attendre et réessayer Ikiform, et un `502` place le statut de Kit dans le journal de livraison.

## Rendu et amélioration progressive

Rien dans le formulaire ne lit la requête pendant le rendu : le layout ne rend donc pas vos pages statiques dynamiques. L'action s'exécute à chaque requête, quel que soit le mode de rendu de la page. Comme l'action va directement dans `action`, React affiche un vrai envoi de formulaire, et un lecteur qui envoie avant le chargement du JavaScript atteint quand même l'action. Le widget Turnstile, lui, a besoin de JavaScript : sans scripts, le lecteur reçoit le message de vérification humaine au lieu d'une inscription. Si ce compromis ne convient pas à vos lecteurs, supprimez la vérification et comptez sur le honeypot et la case obligatoire.

## Déployer

| Hébergeur | Que faire |
| --- | --- |
| Vercel | Ajoutez les six variables dans les **Environment Variables** du projet. Faites pointer le webhook Ikiform vers votre domaine de production ; les déploiements de preview peuvent être derrière la Deployment Protection de Vercel, qui refoule le webhook. |
| Netlify | Netlify build Next.js avec son propre adaptateur. Ajoutez les variables dans les réglages d'environnement du site, avec le scope Functions, et redéployez. |
| Cloudflare | Faites tourner Next.js sur Workers via OpenNext. Définissez les variables dans les réglages du Worker, et donnez-lui un domaine personnalisé : Ikiform refuse les URL de webhook `workers.dev`. |

## Tester gratuitement sur localhost

Un espace de travail sans offre accepte 50 soumissions de test par mois depuis `localhost`. Grâce à l'`Origin` transmis, `next dev` sur `http://localhost:3000` compte comme tel. Utilisez dans `.env.local` les clés de test Turnstile publiées par Cloudflare pour que la vérification passe toujours sur votre machine. Les webhooks ne peuvent pas atteindre `localhost` ; pour essayer le route handler, exposez-le avec un tunnel qui fournit une URL HTTPS, ou utilisez **Send test** dans le tableau de bord une fois déployé.

## Vérifiez que tout fonctionne

1. Lancez `next dev`, descendez jusqu'au pied de page et abonnez-vous avec une adresse à vous. La ligne invitant à vérifier votre boîte remplace le formulaire.
2. Ouvrez **Submissions**. L'inscription a `consent` à `true` et `source` à `footer`.
3. Retirez `required` de la case dans les outils de développement et envoyez sans cocher. Le message de consentement apparaît et rien n'est stocké.
4. Bloquez `challenges.cloudflare.com` dans les outils de développement et envoyez. Le message de vérification humaine apparaît et rien n'est stocké.
5. Si vous avez ajouté un webhook, ouvrez l'endpoint dans **Webhooks**. La livraison affiche `succeeded` avec la réponse de votre serveur.
6. Ouvrez Kit. L'abonné y est en inactive, et l'e-mail de confirmation de Kit est dans votre boîte.
7. Envoyez avec le honeypot rempli, `_gotcha=bot`. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook.

## Pièges

| Problème | Que faire |
| --- | --- |
| `402 plan_required` à chaque test local | L'envoi est arrivé à Ikiform sans origine `localhost`. Vérifiez la ligne `Origin` dans l'action, et ouvrez l'application sur `localhost`, pas sur votre adresse réseau. |
| La vérification humaine échoue à chaque essai | La clé de site et le secret viennent de widgets différents, ou le widget n'est pas configuré pour ce nom d'hôte. Les clés de test ne passent qu'avec le secret de test. |
| Toutes les pages statiques sont devenues dynamiques après l'ajout du pied de page | Quelque chose dans le formulaire ou le layout appelle `headers()` ou `cookies()` pendant le rendu. Ne lisez la requête que dans l'action. |
| Failed to find Server Action, juste après un déploiement | Un onglet exécute encore le build précédent, dont les id d'action ont changé. Le lecteur recharge et ça fonctionne. |
| Les livraisons de webhook affichent `307` ou `308` | Une redirection du proxy ou le réglage de barre oblique finale a intercepté `/api/ikiform`. Excluez-le du matcher ou enregistrez l'URL avec la barre oblique. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre **Spam** pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |

## Questions

### La server action doit-elle ajouter directement l'abonné à mon outil d'e-mailing ?

Non. Ikiform répond au spam avec le même succès qu'à une vraie inscription : l'action ne peut donc pas les distinguer. Laissez le webhook d'Ikiform piloter l'outil d'e-mailing : il n'est envoyé que pour les inscriptions qui ne sont pas du spam.

### Puis-je utiliser la vérification humaine d'Ikiform depuis une server action Next.js ?

Pas avec une clé secrète, qui la contourne. Exécutez vous-même Turnstile dans l'action, comme sur cette page, ou envoyez depuis le navigateur avec @ikiform/sdk, qui conserve la vérification d'Ikiform, les domaines autorisés et les limites par visiteur.

### Ikiform est-il gratuit ?

Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.

## Autres guides

- [Formulaire d'inscription à la newsletter](https://ikiform.com/fr/guides/newsletter-signup.md): Créez un formulaire d'inscription à la newsletter avec votre propre markup, enregistrez le consentement, bloquez les inscriptions de bots et transmettez chaque abonné à Kit, MailerLite, beehiiv ou n'importe quel outil.
- [Kit (ConvertKit)](https://ikiform.com/fr/guides/newsletter-signup/kit.md): Reliez votre propre formulaire de newsletter à Kit avec un relais qui crée l'abonné, l'ajoute à un formulaire Kit pour l'incentive email et étiquette l'emplacement.
- [Double opt-in](https://ikiform.com/fr/guides/newsletter-signup/double-opt-in.md): Ikiform n'envoie pas d'e-mails de confirmation : le double opt-in se fait donc dans votre outil d'e-mailing. Un relais ajoute chaque inscription comme non confirmée et l'outil envoie le lien.
- [Formulaire de newsletter Astro](https://ikiform.com/fr/guides/newsletter-signup/astro.md): Un formulaire de newsletter Astro qui reste sur des pages prérendues : une Astro Action vérifie le consentement et envoie à Ikiform, et un endpoint ajoute les lecteurs à MailerLite.
- [Site statique](https://ikiform.com/fr/guides/newsletter-signup/static-site.md): Ajoutez un formulaire d'inscription à la newsletter à un site statique Hugo, Jekyll, Eleventy ou Astro, gardez la clé d'API de l'outil d'e-mailing hors de la page et confirmez dans l'outil.
- [MailerLite](https://ikiform.com/fr/guides/newsletter-signup/mailerlite.md): Reliez votre propre formulaire d'inscription à MailerLite : un relais ajoute les nouveaux lecteurs comme non confirmés pour le double opt-in, sans jamais demander deux fois à un abonné de confirmer.
