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.
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'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 |
- Le lecteur remplit le formulaire du pied de page. Il envoie à la server action de la page qu'il est en train de lire.
- L'action vérifie la case de consentement, puis vérifie le token Turnstile auprès de Cloudflare.
- Si l'une des vérifications échoue, le formulaire revient avec le message et l'e-mail saisi. Rien n'est envoyé.
- Sinon, l'action envoie l'inscription à Ikiform avec la clé secrète et l'Origin du navigateur.
- 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.
- 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.
Mise en place du projet
app/
layout.tsx
newsletter/
actions.ts
newsletter-form.tsx
submit-button.tsx
page.tsx
api/ikiform/route.ts
lib/
turnstile.ts
.env.localCré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.
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=1234567Collez 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.
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
"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.
sourceest comparé à une liste fixe. Un envoi forgé ne peut pas remplir votre rapport d'emplacements de déchets.- La ligne
Origintransmet l'origine du navigateur, car c'est grâce à cet en-tête qu'Ikiform reconnaît les tests gratuits depuislocalhost, et lefetchd'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
defaultValueles 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.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le formulaire et son état d'attente
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>;
}"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>
);
}useFormStatusdoit être lu dans un enfant du<form>, c'est pourquoi le bouton est un composant à part.useIdgarde les id d'input uniques quand le formulaire du pied de page et celui d'une page/newslettersont affichés ensemble.- Le script Turnstile se charge avec
?render=explicit.renderplace 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
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.
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, anciennementmiddleware.ts, qui redirige les visiteurs sans session, gardez/api/ikiformhors de sonmatcher. Sinon, chaque livraison reçoit votre redirection de connexion au lieu du handler. - Avec
trailingSlash: truedansnext.config, Next.js redirige/api/ikiformvers/api/ikiform/. Enregistrez l'URL avec la barre oblique. - Un
503avecRetry-Afterfait attendre et réessayer Ikiform, et un502place 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
- 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. - Ouvrez Submissions. L'inscription a
consentàtrueetsourceàfooter. - Retirez
requiredde la case dans les outils de développement et envoyez sans cocher. Le message de consentement apparaît et rien n'est stocké. - Bloquez
challenges.cloudflare.comdans les outils de développement et envoyez. Le message de vérification humaine apparaît et rien n'est stocké. - Si vous avez ajouté un webhook, ouvrez l'endpoint dans Webhooks. La livraison affiche
succeededavec la réponse de votre serveur. - Ouvrez Kit. L'abonné y est en inactive, et l'e-mail de confirmation de Kit est dans votre boîte.
- 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
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.
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.
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.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.