Guides19 min de lecture
Un formulaire d'inscription à la newsletter sans l'embed de Mailchimp
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.
Écrivez le formulaire d'inscription dans votre propre HTML et faites-le pointer vers un backend de formulaires au lieu de coller le code d'embed de votre outil d'e-mailing. Le backend valide l'adresse et la case de consentement, filtre les bots et envoie un webhook signé, et un petit relais ajoute l'abonné via l'API de votre outil d'e-mailing. L'outil envoie ensuite l'e-mail de confirmation, la newsletter et les liens de désinscription : changer d'outil plus tard revient à changer le relais, pas chaque formulaire.
Chaque outil d'e-mailing vous donne un embed : un bloc de markup généré à coller dans votre page. Ça fonctionne, mais le formulaire appartient alors à l'outil. Il envoie directement chez ce fournisseur, il porte son markup et souvent ses styles et ses scripts, et le jour où vous passez à un autre outil, vous traquez chaque copie sur tout votre site. Ce guide construit un formulaire d'inscription qui vous appartient, dans le markup et le framework que vous utilisez déjà, et le relie à l'outil qui envoie votre newsletter.
Le trajet d'une inscription
Il y a deux tâches ici, et elles ont leur place à des endroits différents. Recueillir une inscription est une tâche de formulaire : valider l'adresse, vérifier la case de consentement, bloquer les bots, enregistrer le moment. Envoyer une newsletter est une tâche d'e-mailing : confirmer l'adresse, envoyer les numéros, gérer les désinscriptions et les rebonds. Ikiform fait la première. Votre outil d'e-mailing fait la seconde. Un webhook signé les relie.
- Un lecteur remplit le formulaire d'inscription de votre page, qui envoie à votre endpoint Ikiform.
- Ikiform vérifie l'adresse, la case de consentement obligatoire, le honeypot, le score de spam et la limite de débit.
- L'inscription acceptée est stockée dans le tableau de bord avec son horodatage, son origine et son referrer.
- Ikiform peut vous écrire à chaque inscription. Il n'écrit jamais au lecteur.
- Ikiform envoie un webhook submission.created signé.
- Un relais vérifie la signature et ajoute le lecteur à votre outil d'e-mailing, qui envoie la confirmation puis chaque numéro.
Quatre façons de recueillir des inscriptions à une newsletter
| Approche | Points forts | Compromis |
|---|---|---|
| L'embed de l'outil d'e-mailing, de Mailchimp, Kit, MailerLite ou beehiiv | Rien à faire tourner. La confirmation, les tags et l'e-mail de bienvenue se configurent dans les réglages de l'outil. | Le markup et le comportement sont ceux de l'outil. Mailchimp précise que ses formulaires intégrés héritent des styles de votre site et peuvent donc avoir un autre rendu sur votre page, et activer son reCAPTCHA sur un formulaire existant oblige à générer et coller un nouveau code. Chaque copie est liée à un seul fournisseur. |
| Votre propre route serveur qui appelle l'API de l'outil | Contrôle total du formulaire et de la requête, avec la clé d'API gardée sur votre serveur. | Vous construisez vous-même la validation, le filtrage des bots, les limites de débit et l'enregistrement du consentement, et il vous faut un serveur même si le site est statique. |
| Un créateur de formulaires hébergé plus une automatisation, comme Typeform avec Zapier | Pas de code, et l'outil d'automatisation connaît tous les fournisseurs d'e-mailing. | Une iframe qui ne ressemble pas à votre page, plus deux abonnements. Webhooks et personnalisation de la marque dépendent de l'offre. |
| Un backend de formulaires, comme Ikiform, plus un relais | Votre propre markup sur n'importe quel site, filtrage du spam, un enregistrement du consentement et des webhooks signés, sans serveur pour le formulaire. | Ikiform n'envoie aucun e-mail au lecteur. La confirmation et chaque newsletter viennent de votre outil d'e-mailing, via un petit relais ou une automatisation. |
Un seul site, un seul outil et son embed vous conviennent ? Gardez-les. Construisez le vôtre quand le formulaire doit suivre votre design, s'étend sur plusieurs sites ou risque de survivre à l'outil.
Le formulaire
Un bon formulaire de newsletter est petit : un e-mail, un prénom facultatif pour la salutation, et une case de consentement dont le libellé est la promesse que vous faites. Un input masqué source enregistre l'endroit du site où l'inscription a eu lieu. Choisissez votre stack ; ce choix s'applique à tous les exemples de code de ces guides.
Collez le formulaire dans votre pied de page, votre barre latérale ou à la fin d'un article. Il envoie de façon classique et arrive sur votre propre page de remerciement : il fonctionne donc sans JavaScript.
<form id="newsletter" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" placeholder="you@example.com" />
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" autocomplete="given-name" />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value="footer" />
<input type="hidden" name="_redirect" value="https://example.com/subscribed" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Subscribe</button>
<p id="nl-status" role="status" hidden></p>
</form>Pour garder le lecteur sur l'article, ajoutez ce script après le formulaire. Il envoie avec fetch, remplace le formulaire par une ligne qui l'invite à vérifier sa boîte de réception, et affiche le message du serveur pour l'e-mail ou la case.
const form = document.querySelector("#newsletter");
const status = document.querySelector("#nl-status");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const body = new FormData(form);
button.disabled = true;
status.hidden = false;
status.textContent = "Subscribing";
try {
const response = await fetch(form.action, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
form.replaceChildren(status);
status.textContent = "Almost done. Open the email we sent you and confirm your address.";
return;
}
const fields = result.error.fields ?? {};
status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? result.error.message;
} catch {
status.textContent = "Couldn't reach the server. Try again.";
}
button.disabled = false;
});Une server action transmet l'inscription avec votre clé secrète. Elle renvoie les erreurs par champ : le message sur l'e-mail se place sous l'input e-mail, et celui sur le consentement sous la case. Le composant accepte une prop source, pour que le pied de page et un encart dans l'article enregistrent des valeurs différentes.
"use server";
import { headers } from "next/headers";
export interface SignupState {
done: boolean;
errors: { email?: string; consent?: string; form?: string };
email: string;
}
export async function subscribe(_previous: SignupState, formData: FormData): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim();
const consent = formData.get("consent") === "true";
if (!consent) return { done: false, errors: { consent: "Tick the box so we know you want the emails." }, email };
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: (await headers()).get("origin") ?? "",
},
body: JSON.stringify({
email,
first_name: String(formData.get("first_name") ?? "").trim() || undefined,
consent,
source: String(formData.get("source") ?? "footer"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { done: true, errors: {}, email };
const fields = result?.error?.fields ?? {};
return {
done: false,
errors: {
email: fields.email?.[0],
consent: fields.consent?.[0],
form: fields.email || fields.consent ? undefined : (result?.error?.message ?? "Couldn't subscribe. Try again."),
},
email,
};
}"use client";
import { useActionState } from "react";
import { type SignupState, subscribe } from "./actions";
const initial: SignupState = { done: false, errors: {}, email: "" };
export function NewsletterForm({ source = "footer" }: { source?: string }) {
const [state, action, pending] = useActionState(subscribe, initial);
if (state.done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<form action={action} noValidate>
<label htmlFor="nl-email">Email</label>
<input
id="nl-email"
name="email"
type="email"
required
autoComplete="email"
defaultValue={state.email}
aria-invalid={Boolean(state.errors.email)}
aria-describedby="nl-email-error"
/>
<p id="nl-email-error">{state.errors.email}</p>
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} autoComplete="given-name" />
<label>
<input name="consent" type="checkbox" value="true" aria-describedby="nl-consent-error" />
Email me the newsletter. I can unsubscribe at any time.
</label>
<p id="nl-consent-error">{state.errors.consent}</p>
<input type="hidden" name="source" value={source} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}Pour aller plus loin avec Next.js, lisez Créer un formulaire d'inscription à la newsletter avec Next.js.
Une Astro Action avec accept: "form" interprète la case comme un booléen et l'e-mail avec z.email(). isInputError distingue une adresse invalide d'un envoi refusé. Les actions de formulaire ont besoin d'une page à la demande : ajoutez donc un adaptateur et mettez export const prerender = false dans chaque page qui affiche le composant ; cet export n'a aucun effet dans un composant.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
subscribe: defineAction({
accept: "form",
input: z.object({
email: z.email(),
first_name: z.string().max(80).nullish(),
consent: z.boolean(),
source: z.string().default("footer"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
if (!input.consent) {
throw new ActionError({ code: "BAD_REQUEST", message: "Tick the box so we know you want the emails." });
}
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...input, first_name: input.first_name ?? undefined }),
});
const result = await response.json();
if (!result.ok) {
throw new ActionError({
code: "BAD_REQUEST",
message: result.error.fields?.email?.[0] ?? result.error.message,
});
}
return { subscribed: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
---
{result?.data?.subscribed ? (
<p role="status">Almost done. Open the email we sent you and confirm your address.</p>
) : (
<form method="POST" action={actions.subscribe}>
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" />
{fields.email && <p>Enter a valid email address.</p>}
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" />
<label>
<input name="consent" type="checkbox" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Subscribe</button>
</form>
)}Pour aller plus loin avec Astro, lisez Créer un formulaire d'inscription à la newsletter avec Astro.
Une fonction POST createServerFn transforme le FormData en JSON dans .validator() et renvoie les erreurs de champ comme données au lieu de lever une exception, pour que le composant place chaque message à côté de son input.
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Errors = { email?: string; consent?: string; form?: string };
const subscribe = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
email: String(data.get("email") ?? "").trim(),
first_name: String(data.get("first_name") ?? "").trim() || undefined,
consent: data.get("consent") === "true",
source: String(data.get("source") ?? "footer"),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }): Promise<{ ok: boolean; errors: Errors }> => {
const origin = getRequestHeader("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(data),
});
const result = await response.json();
if (result.ok) return { ok: true, errors: {} };
const fields = result.error.fields ?? {};
return {
ok: false,
errors: { email: fields.email?.[0], consent: fields.consent?.[0], form: result.error.message },
};
});
export function NewsletterForm() {
const send = useServerFn(subscribe);
const [done, setDone] = useState(false);
const [errors, setErrors] = useState<Errors>({});
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
const result = await send({ data: new FormData(event.currentTarget) });
setDone(result.ok);
setErrors(result.errors);
} catch {
setErrors({ form: "Couldn't subscribe. Try again." });
} finally {
setPending(false);
}
}
if (done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autoComplete="email" />
{errors.email ? <p>{errors.email}</p> : null}
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
{errors.consent ? <p>{errors.consent}</p> : null}
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form && !errors.email && !errors.consent ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}Pour aller plus loin avec TanStack Start, lisez Créer un formulaire d'inscription à la newsletter avec TanStack Start.
Un formulaire de pied de page ne devrait pas quitter la page en cours de lecture. Placez l'action dans une resource route à /newsletter et envoyez-y avec useFetcher, qui soumet sans navigation et garde le résultat sur la page courante.
import { data } from "react-router";
import type { Route } from "./+types/newsletter";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
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: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email: String(formData.get("email") ?? "").trim(),
first_name: String(formData.get("first_name") ?? "").trim() || undefined,
consent: formData.get("consent") === "true",
source: String(formData.get("source") ?? "footer"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { ok: true as const };
return data(
{ ok: false as const, fields: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) },
{ status: response.status },
);
}import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [index("routes/home.tsx"), route("newsletter", "routes/newsletter.tsx")] satisfies RouteConfig;import { useFetcher } from "react-router";
import type { action } from "~/routes/newsletter";
export function NewsletterForm() {
const fetcher = useFetcher<typeof action>();
const result = fetcher.data;
const busy = fetcher.state !== "idle";
if (result?.ok) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<fetcher.Form method="post" action="/newsletter">
<label htmlFor="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autoComplete="email" />
{result?.fields.email ? <p>{result.fields.email[0]}</p> : null}
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
{result?.fields.consent ? <p>{result.fields.consent[0]}</p> : null}
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{result && !result.fields.email && !result.fields.consent ? <p role="alert">{result.message}</p> : null}
<button disabled={busy}>{busy ? "Subscribing" : "Subscribe"}</button>
</fetcher.Form>
);
}Pour aller plus loin avec React Router, lisez Créer un formulaire d'inscription à une newsletter avec React Router.
Les actions vivent dans un +page.server.ts, jamais dans un layout : l'action est donc sur une page /newsletter et le pied de page envoie à /newsletter?/subscribe. Le callback use:enhance personnalisé traite lui-même le résultat au lieu d'appeler update(), pour que le lecteur reste sur l'article. Déclarez IKIFORM_SECRET_KEY dans src/env.ts avec defineEnvVars pour que $app/env/private puisse la lire.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
subscribe: async ({ request, fetch, url }) => {
const form = await request.formData();
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
email: String(form.get("email") ?? "").trim(),
first_name: String(form.get("first_name") ?? "").trim() || undefined,
consent: form.get("consent") === "true",
source: String(form.get("source") ?? "footer"),
_gotcha: String(form.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
const fields = result.error.fields ?? {};
return fail(response.status, { message: String(fields.email?.[0] ?? fields.consent?.[0] ?? result.error.message) });
}
return { subscribed: true };
},
} satisfies Actions;<script lang="ts">
import { applyAction, enhance } from "$app/forms";
let done = $state(false);
let message = $state("");
let pending = $state(false);
</script>
{#if done}
<p role="status">Almost done. Open the email we sent you and confirm your address.</p>
{:else}
<form
method="POST"
action="/newsletter?/subscribe"
use:enhance={() => {
pending = true;
return async ({ result }) => {
pending = false;
if (result.type === "success") done = true;
else if (result.type === "failure") message = String(result.data?.message ?? "Couldn't subscribe.");
else await applyAction(result);
};
}}
>
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" />
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if message}
<p role="alert">{message}</p>
{/if}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
{/if}Pour aller plus loin avec SvelteKit, lisez Créer un formulaire d'inscription à une newsletter avec SvelteKit.
Une route serveur garde la clé secrète hors du navigateur et renvoie les erreurs de champ d'Ikiform dans le data de l'erreur. Définissez NUXT_IKIFORM_SECRET_KEY, et déclarez ikiformSecretKey sous runtimeConfig dans nuxt.config.ts.
interface Signup {
email?: string;
first_name?: string;
consent?: boolean;
source?: string;
_gotcha?: string;
}
export default defineEventHandler(async (event) => {
const body = await readBody<Signup>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: body.email?.trim() ?? "",
first_name: body.first_name?.trim() || undefined,
consent: body.consent === true,
source: body.source ?? "footer",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { subscribed: true };
});<script setup lang="ts">
const email = ref("");
const firstName = ref("");
const consent = ref(false);
const trap = ref("");
const status = ref<"idle" | "sending" | "done">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");
async function subscribe() {
status.value = "sending";
errors.value = {};
message.value = "";
try {
await $fetch("/api/newsletter", {
method: "POST",
body: { email: email.value, first_name: firstName.value, consent: consent.value, source: "footer", _gotcha: trap.value },
});
status.value = "done";
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
errors.value = data?.data?.fields ?? {};
message.value = data?.message ?? "Couldn't subscribe. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
<form v-else @submit.prevent="subscribe">
<label for="nl-email">Email</label>
<input id="nl-email" v-model="email" name="email" type="email" required autocomplete="email" />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="nl-name">First name (optional)</label>
<input id="nl-name" v-model="firstName" name="first_name" type="text" maxlength="80" />
<label>
<input v-model="consent" name="consent" type="checkbox" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<p v-if="errors.consent">{{ errors.consent[0] }}</p>
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="message && !errors.email && !errors.consent" role="alert">{{ message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
</form>
</template>Pour aller plus loin avec Nuxt, lisez Créer un formulaire d'inscription à une newsletter avec Nuxt.
Une single-page app envoie depuis le navigateur. submit() de @ikiform/sdk envoie le FormData et se résout toujours avec ok et, en cas d'échec, les erreurs de champ : il n'y a donc pas de bloc try à écrire. Ne mettez jamais de clé secrète ici ; c'est Allowed domains (domaines autorisés) qui protège le formulaire.
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";
type FieldErrors = Record<string, string[]>;
export function NewsletterForm() {
const [done, setDone] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const [message, setMessage] = useState("");
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setErrors({});
setMessage("");
const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(event.currentTarget));
setPending(false);
if (result.ok) {
setDone(true);
return;
}
setErrors(result.error.fields ?? {});
setMessage(result.error.message);
}
if (done) return <p role="status">Almost done. Open the email we sent you and confirm your address.</p>;
return (
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" onSubmit={onSubmit}>
<label htmlFor="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autoComplete="email" />
{errors.email ? <p>{errors.email[0]}</p> : null}
<label htmlFor="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxLength={80} />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
{errors.consent ? <p>{errors.consent[0]}</p> : null}
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{message && !errors.email && !errors.consent ? <p role="alert">{message}</p> : null}
<button disabled={pending}>{pending ? "Subscribing" : "Subscribe"}</button>
</form>
);
}Le même appel submit() dans un composant Vue monofichier. Le formulaire garde son action : il envoie donc quand même si le script ne se charge pas.
<script setup lang="ts">
import { submit } from "@ikiform/sdk";
import { ref } from "vue";
const status = ref<"idle" | "sending" | "done">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");
async function subscribe(event: SubmitEvent) {
status.value = "sending";
const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(event.target as HTMLFormElement));
if (result.ok) {
status.value = "done";
return;
}
errors.value = result.error.fields ?? {};
message.value = result.error.message;
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'done'" role="status">Almost done. Open the email we sent you and confirm your address.</p>
<form v-else action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" @submit.prevent="subscribe">
<label for="nl-email">Email</label>
<input id="nl-email" name="email" type="email" required autocomplete="email" />
<p v-if="errors.email">{{ errors.email[0] }}</p>
<label for="nl-name">First name (optional)</label>
<input id="nl-name" name="first_name" type="text" maxlength="80" />
<label>
<input name="consent" type="checkbox" value="true" required />
Email me the newsletter. I can unsubscribe at any time.
</label>
<p v-if="errors.consent">{{ errors.consent[0] }}</p>
<input type="hidden" name="source" value="footer" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="message && !errors.email && !errors.consent" role="alert">{{ message }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Subscribing" : "Subscribe" }}</button>
</form>
</template>- Remplacez
frm_xxxxxxxxxxxxxxxxpar l'id de votre formulaire, visible dans le tableau de bord. - Gardez
emailcomme nom de l'input e-mail. Ikiform le valide, met le domaine en minuscules et le compare aux domaines d'e-mails jetables. - L'input masqué
_gotchaest le honeypot. Les humains ne le voient jamais, et les bots qui remplissent tous les inputs se signalent eux-mêmes comme spam. - La case envoie
truequand elle est cochée. Décochée, elle n'envoie rien, et une case obligatoire est refusée avecThis box must be checked. - La ligne de succès invite à vérifier la boîte de réception, car votre outil d'e-mailing envoie une confirmation. Si vous avez désactivé le double opt-in, remplacez-la par un simple merci.
- Les versions côté serveur transmettent l'en-tête
Origindu navigateur, car c'est grâce à lui qu'Ikiform reconnaît les tests gratuits depuislocalhost, et lefetchd'un serveur n'en envoie aucun.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.La configuration des frameworks est dans la documentation : Next.js, Astro, SvelteKit, Nuxt, React, Vue et HTML simple. Le modèle de formulaire de newsletter génère les mêmes champs en HTML, en React et en server action Next.js.
Les champs et la définition
Collez la définition ci-dessous dans l'onglet Fields du formulaire et Ikiform l'applique côté serveur, quoi qu'envoie la page. L'aperçu exécute le même validateur dans votre navigateur : laissez la case décochée et envoyez pour voir l'erreur que recevrait un bot ou une page cassée.
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
source est un input masqué. Il n'est affiché ici en champ texte que pour que vous puissiez l'essayer.| Champ | Pourquoi | À garder en tête |
|---|---|---|
email | La personne abonnée | Obligatoire. type="email" affiche le bon clavier sur mobile |
first_name | Une salutation dans chaque numéro | Facultatif. La plupart des outils d'e-mailing ont un champ prénom auquel l'associer |
consent | Une preuve que la personne a demandé à recevoir des e-mails | Case obligatoire. Stockée comme true |
source | Quel formulaire du site a fonctionné : pied de page, article, popup | Masqué. Définissez une valeur différente par emplacement, et transmettez-la comme tag ou champ |
Une fois les champs définis, Ikiform refuse tout input qu'il ne connaît pas avec unknown_field : ajoutez donc un champ à la définition avant d'ajouter un input à la page. Les noms qui commencent par _, comme _redirect, sont des instructions pour Ikiform et ne comptent jamais comme des champs.
Enregistrer le consentement
Une newsletter, ce sont des e-mails que quelqu'un a demandés. La case rend cette demande explicite, et la soumission stockée l'enregistre. Voici ce qu'Ikiform conserve pour chaque inscription acceptée :
{
"email": "ada@example.com",
"first_name": "Ada",
"consent": true,
"source": "footer"
}À côté des champs, chaque soumission a son horodatage created_at, l'origine et le referrer de la page, le pays et le user agent du navigateur. Ensemble, ils montrent quand et où la personne a coché la case. Deux habitudes rendent cet enregistrement utile par la suite :
- Rédigez le libellé comme une promesse. « Envoyez-moi la newsletter. Je peux me désinscrire à tout moment. » dit ce que la personne reçoit. Si vous envoyez aussi des offres, dites-le dans le libellé.
- Notez la date quand la formulation change. Un
truestocké dit que la personne a accepté, pas quel texte. Tenez un court historique daté du libellé, pour pouvoir rapprocher chaque inscription des mots qu'elle a vus.
Ikiform conserve l'enregistrement ; il ne peut pas vous dire ce qu'exigent les règles là où vous et vos lecteurs vivez. Vérifiez-les avec quelqu'un qui les connaît.
Les e-mails de confirmation viennent de votre outil d'e-mailing
Ikiform n'envoie pas d'e-mail à la personne qui remplit le formulaire. Les e-mails de notification vont uniquement à vous et à votre équipe : il n'y a donc ni réponse automatique, ni e-mail de confirmation, ni double opt-in. Envoyez-les depuis votre outil d'e-mailing, déclenché par un webhook. Cette séparation est voulue. L'outil qui envoie votre newsletter suit déjà qui a confirmé, qui s'est désinscrit et quelles adresses rebondissent. Si un second système envoyait les confirmations, ces données finiraient par diverger.
Le relais ajoute donc le lecteur dans un état qui pousse l'outil d'e-mailing à envoyer sa propre confirmation, et l'outil le marque comme actif quand il clique. Chaque outil active cela différemment : un réglage du formulaire, une option du compte, ou un paramètre de la requête API. Le guide du double opt-in couvre les trois cas, et ce qui se passe quand un abonné connu s'inscrit à nouveau.
Le relais
Un relais est une courte fonction qui reçoit le webhook d'Ikiform, vérifie sa signature et appelle l'API de votre outil d'e-mailing. Il lui faut une URL HTTPS publique et un secret de chaque côté. Celui-ci est un route handler Next.js ; le même code tourne dans tout runtime qui dispose de fetch.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
import { addSubscriber } from "@/lib/email-tool";
export async function POST(request: Request) {
const payload = await request.text();
let event;
try {
event = await verifyWebhook({ payload, 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 fields = event.data.fields;
if (fields.consent !== true) return new Response("No consent, skipped", { status: 200 });
const answer = await addSubscriber({
email: String(fields.email),
firstName: typeof fields.first_name === "string" ? fields.first_name : undefined,
source: typeof fields.source === "string" ? fields.source : "unknown",
referrer: typeof event.data.meta.referrer === "string" ? event.data.meta.referrer : undefined,
});
if (answer.status === 429) {
return new Response("Email tool rate limit", {
status: 503,
headers: { "retry-after": answer.headers.get("retry-after") ?? "60" },
});
}
if (!answer.ok) return new Response(`Email tool answered ${answer.status}`, { status: 502 });
return new Response(null, { status: 204 });
}verifyWebhookvérifie la signature HMAC et l'horodatage. Sans lui, quiconque trouve l'URL peut ajouter des adresses à votre liste.- Les événements
endpoint.test, envoyés par Send test dans le tableau de bord, reçoivent204et sont ignorés. - La vérification du consentement est un second verrou derrière la définition du formulaire.
addSubscriberest la seule partie qui dépend de l'outil. Son contenu pour chaque outil est sur les pages Kit, MailerLite et beehiiv.
Ce que répond le relais décide de ce que fait Ikiform ensuite :
- L'outil d'e-mailing ajoute ou met à jour l'abonné : le relais répond 204 et la livraison est marquée comme réussie.
- L'outil d'e-mailing limite le débit : le relais répond 503 avec Retry-After, et Ikiform attend ce délai avant la tentative suivante.
- L'outil d'e-mailing refuse la requête ou est en panne : le relais répond 502, et Ikiform réessaie selon son calendrier avec le même webhook-id.
Ajoutez l'URL du relais sous Webhooks dans le tableau de bord, choisissez le formulaire de newsletter et copiez le secret whsec_ dans IKIFORM_WEBHOOK_SECRET. Un espace de travail a jusqu'à 3 endpoints. L'URL doit être en HTTPS public sur le port 443 ; les noms d'hôte workers.dev et pages.dev sont refusés, donnez donc un domaine personnalisé à un Worker. Les nouvelles tentatives, les payloads et le schéma de signature sont dans la documentation des webhooks, et les soumissions de formulaire vers votre propre serveur propose une version qui stocke chaque livraison une seule fois.
Tenir à l'écart les bots et le list bombing
Les inscriptions de bots nuisent davantage à une newsletter qu'à la plupart des formulaires. Une vraie adresse inscrite par un bot reçoit des e-mails qu'elle n'a jamais demandés, et les plaintes de ces personnes nuisent au domaine depuis lequel vous envoyez. Certains bots inscrivent l'adresse d'une victime à des centaines de formulaires d'un coup pour noyer sa boîte de réception, et chaque formulaire qui envoie un e-mail de confirmation y participe.
| Défense | Ce qu'elle arrête | Configuration |
|---|---|---|
| Honeypot | Les bots qui remplissent tous les inputs | L'input masqué _gotcha |
| Consentement obligatoire | Les scripts qui n'envoient qu'un e-mail | La case consent avec required: true dans la définition |
| Score de spam | Adresses jetables (+2), répétitions identiques (+3), liens et phrases de spam. À 5, une inscription est stockée comme spam | Aucun |
| Limites de débit | Un visiteur qui envoie en boucle | Activée par défaut : Standard, 5 par minute et par visiteur |
| Vérification humaine | Les bots qui pilotent un vrai navigateur | Réglez Spam check sur Ikiform pour Cloudflare Turnstile, sans clés à gérer |
| Domaines autorisés | L'endpoint de votre formulaire copié sur d'autres sites | Listez chaque domaine où apparaît le formulaire |
Le spam reçoit la même réponse de succès qu'une vraie inscription et n'envoie aucun webhook : il n'atteint donc jamais votre outil d'e-mailing et ne déclenche jamais d'e-mail de confirmation. Si un vrai lecteur tombe en spam, marquez-le comme non-spam et son webhook part à ce moment-là. La documentation anti-spam explique chaque vérification et son score.
Un formulaire sur de nombreuses pages
Un même endpoint peut se trouver dans le pied de page de chaque page, à la fin de chaque article et sur une page /newsletter. Donnez à chaque emplacement sa propre valeur source pour savoir lequel rapporte des inscriptions :
footer: vu sur chaque page. Limitez-le à l'e-mail et à la case.post: des lecteurs qui ont fini un article, souvent ceux qui convertissent le mieux.newsletter-page: là où pointent vos profils sociaux. De la place pour un numéro d'exemple.
Chaque soumission enregistre aussi le referrer, qui est la page exacte où se trouvait le lecteur : source vous donne l'emplacement et le referrer l'article. Ajoutez chaque domaine qui affiche le formulaire sous Allowed domains, jusqu'à 20 par formulaire ; les sous-domaines d'un domaine listé sont autorisés automatiquement.
Le même lecteur deux fois
Ikiform ne fusionne ni ne rejette les soumissions répétées par e-mail. Une répétition identique dans les 24 heures ne fait qu'augmenter le score de spam. Dédupliquez là où vit la liste, ou à l'export. Pour une newsletter, c'est rarement un problème : Kit, MailerLite et beehiiv identifient chacun les abonnés par e-mail, donc une répétition met à jour un seul contact au lieu d'en créer un second.
Changer d'outil d'e-mailing plus tard
C'est là que posséder le formulaire est rentable. Passer d'un outil d'e-mailing à un autre implique normalement de remplacer chaque embed du site. Ici, le formulaire ne sait pas quel outil se trouve derrière lui :
- Avec un embed, chaque page porte du code qui envoie directement à l'ancien outil : migrer, c'est remplacer chaque copie.
- Avec votre propre formulaire, chaque page continue d'envoyer à Ikiform, et Ikiform continue d'envoyer le même webhook.
- Seule la fonction addSubscriber du relais change, pour appeler l'API du nouvel outil.
Migrez les abonnés existants avec l'export de l'ancien outil, car lui seul sait qui a confirmé et qui est parti. L'export CSV d'Ikiform, jusqu'à 100 000 lignes par fichier, sert à vérifier les chiffres, pas à importer.
Choisissez votre cas
Le formulaire ci-dessus est le même partout. Ces pages couvrent les e-mails de confirmation, les sites sans serveur et les appels API exacts pour trois outils d'e-mailing.
- Double opt-inIkiform 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.
- Site statiqueAjoutez 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.
- Kit (ConvertKit)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.
- MailerLiteReliez 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.
- beehiivReliez votre propre formulaire d'inscription à beehiiv : un relais crée chaque abonnement avec le double opt-in activé, l'emplacement comme source UTM et la page comme referrer.
- Formulaire de newsletter Next.jsUn 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.
- Formulaire de newsletter AstroUn 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.
- Formulaire de newsletter TanStack StartUn formulaire de newsletter TanStack Start : une server function pour les inscriptions dans la page, une server route en repli sans JavaScript, et les vraies inscriptions envoyées à beehiiv.
- Formulaire de newsletter React RouterUn formulaire de newsletter en pied de page avec React Router : useFetcher envoie à une route /newsletter qui fonctionne aussi sans JavaScript, et une resource route alimente MailerLite.
- Formulaire de newsletter SvelteKitUn formulaire de newsletter SvelteKit 3 dans le layout : une form action avec consentement et limite par IP, un use:enhance qui reste sur la page, et une route de webhook pour Kit.
- Formulaire de newsletter NuxtUn formulaire de newsletter Nuxt qui fonctionne avec ou sans JavaScript : une route serveur avec vérification Turnstile envoie à Ikiform, et une route Nitro alimente beehiiv.
Vérifiez que tout fonctionne
Faites ces vérifications avant d'ajouter le formulaire à chaque page. Ikiform n'a pas d'offre gratuite. Les tests sont gratuits : un espace de travail sans offre accepte 50 soumissions de test par mois depuis localhost. Pour passer en production, choisissez Pro à partir de 6 $/mois pour 500 soumissions, ou payez une seule fois : 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais. Voir les tarifs.
- Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus
localhostpendant le développement. Laissez vide pour accepter les envois de partout. - Envoyez le formulaire avec la case décochée depuis les outils de développement du navigateur, ou retirez
requiredun instant. Vous obtenez422avecThis box must be checkedet rien n'est stocké. - Envoyez-le correctement avec une adresse à vous. Vous voyez la ligne vous invitant à vérifier votre boîte, ou vous arrivez sur votre page de remerciement.
- Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
- Ouvrez l'endpoint sous Webhooks. La livraison affiche
succeededet la réponse de votre relais. - Ouvrez votre outil d'e-mailing. L'abonné y est, non confirmé si le double opt-in est activé, et l'e-mail de confirmation 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. - Rien n'est arrivé ? Passez le filtre de statut du tableau de bord sur Spam. Le spam reçoit la même réponse de succès qu'une vraie soumission.
Dépannage
| Problème | Que faire |
|---|---|
422 validation_failed avec consent | La case n'a pas été cochée, ou le name de son input n'est pas consent. Une version côté serveur doit envoyer true, "true" ou "on" ; une case décochée n'envoie rien. |
422 validation_failed avec unknown_field | La page envoie un input que la définition ne liste pas, souvent un source masqué ou un input de suivi ajouté plus tard. Ajoutez-le à la définition, ou supprimez l'input. |
403 origin_not_allowed, ou tous les envois finissent en spam | Le domaine de la page n'est pas dans les Allowed domains du formulaire. Ajoutez-le, plus localhost pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés. |
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. |
| 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. |
| Les inscriptions arrivent dans Ikiform mais pas dans l'outil d'e-mailing | Lisez le journal de livraison sous Webhooks. 400 signifie que le secret du relais est faux. 502 porte le statut de l'outil d'e-mailing : généralement une clé d'API, un id de liste ou un id de formulaire erroné. |
| L'abonné est dans l'outil d'e-mailing mais n'a reçu aucun e-mail de confirmation | Le double opt-in est désactivé dans l'outil, ou le relais a créé l'abonné comme déjà confirmé. Voir double opt-in pour le réglage de chaque outil. |
| Le navigateur arrive sur la page de remerciement d'Ikiform au lieu de la vôtre | _redirect est ignoré sauf si son domaine figure dans Allowed domains et qu'il s'agit d'une URL https:. Ajoutez le domaine, ou définissez plutôt Redirect after submit dans les réglages du formulaire. |
Questions
Oui. Écrivez le formulaire dans votre propre HTML, envoyez-le à un backend de formulaires comme Ikiform, et transmettez chaque inscription par webhook à Mailchimp ou à tout autre outil d'e-mailing via son API. Le formulaire ressemble alors au reste de votre site et ne change pas quand l'outil d'e-mailing change.
Non. Ikiform recueille, valide et stocke les inscriptions, puis les transmet par webhook. Il n'écrit jamais à l'abonné : l'e-mail de confirmation, chaque numéro et le lien de désinscription viennent de votre outil d'e-mailing.
Tout outil qui a une API pour ajouter des abonnés, ou tout outil pris en charge par un service d'automatisation comme Zapier, Make ou n8n. Il n'y a pas d'intégrations en un clic ; c'est le relais ou l'automatisation qui fait le lien. Kit, MailerLite et beehiiv ont des pages pas à pas dans ce guide.
Dans les variables d'environnement côté serveur du relais, jamais dans la page. La clé d'API d'un outil d'e-mailing peut généralement lire et modifier toute la liste : une clé dans du JavaScript du navigateur, c'est une clé que n'importe qui peut copier.
Gardez le honeypot masqué _gotcha et la case de consentement obligatoire, listez vos domaines sous Allowed domains et réglez la vérification anti-spam sur Ikiform pour une vérification Cloudflare Turnstile. Les inscriptions stockées comme spam n'envoient jamais de webhook, elles ne déclenchent donc jamais d'e-mail de confirmation.
Non. Chaque envoi accepté est stocké. Une répétition identique dans les 24 heures augmente le score de spam mais n'est pas du spam en soi : dédoublonnez donc par e-mail dans votre liste, votre base de données ou votre export CSV.
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.