Formulaire d'inscription à la newsletter

Formulaire d'inscription à la newsletter9 min de lecture

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.

Preet SutharFondateur, Ikiform

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

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'attentionCe qui se passeCe que fait cette version
Pages statiquesLe formulaire ne doit lire ni cookies ni en-têtes pendant le rendu, sinon chaque page devient dynamiqueLe formulaire ne lit rien au rendu. Seule l'action touche à la requête
La vérification humaineUn envoi avec clé secrète contourne la vérification Turnstile d'IkiformEffectue sa propre vérification Turnstile dans l'action
Qui peut l'appelerUne server action est un endpoint POST public, joignable sans votre formulaireValide le consentement et le token avant tout envoi
Comment une inscription depuis le pied de page Next.js atteint Ikiform puis Kitpendant que le lecteur attendquelques secondes plus tardformulairedepieddepagelayoutracine,chaquepageserveractionconsentement,puisTurnstileIkiformclésecrète,stockel'inscriptionétatdel'actionvérifiezvotreboîteétatd'erreurrienenvoyé,e-mailgardéIkiformseulementsinon-spam/api/ikiformvérifielasignatureKitenvoielaconfirmationFormDataJSON + Originrésultatvérification échouéewebhook signév4 APIComment une inscription depuis le pied de page Next.js atteint Ikiform puis Kitpendant que le lecteur attendquelques secondes plus tardformulaire depied de pagelayout racine,chaque pageserveractionconsentement,puisTurnstileIkiformclé secrète, stockel'inscriptionétat de l'actionvérifiez votreboîteétatd'erreurrienenvoyé,e-mailgardéIkiformseulement sinon-spam/api/ikiformvérifie lasignatureKitenvoie laconfirmationFormDataJSON + Originrésultatwebhook signév4 APIvérification échouée
  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 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
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
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 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
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
"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 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
"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
"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
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 couvre les tags et la signification de chaque réponse de Kit.

app/api/ikiform/route.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ébergeurQue faire
VercelAjoutez 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.
NetlifyNetlify 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.
CloudflareFaites 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èmeQue faire
402 plan_required à chaque test localL'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 essaiLa 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 pageQuelque 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éploiementUn 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 308Une 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

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.