Guides19 min de lecture
Comment créer une liste d'attente pour un nouveau SaaS
Créez une liste d'attente avant lancement sur votre landing page : le formulaire, l'anti-spam, les doublons, les e-mails de confirmation et un export propre le jour J.
Placez sur votre landing page un formulaire HTML simple qui envoie l'e-mail du visiteur à un backend de formulaires, pour ne pas gérer de serveur avant le lancement. Transmettez chaque inscription par webhook à l'outil d'e-mailing avec lequel vous lancerez, et laissez cet outil envoyer les éventuelles confirmations. Le jour du lancement, exportez la liste en CSV, supprimez les doublons par e-mail et envoyez un seul e-mail de lancement.
Une liste d'attente est le test le moins cher d'une idée de SaaS. Si des inconnus laissent leur e-mail pour quelque chose qui n'existe pas encore, vous avez une raison de le construire, et une liste de personnes à prévenir quand il sortira. Ce guide en construit une sur votre propre landing page, sans base de données ni serveur, et la prépare pour le jour du lancement.
Comment fonctionne une liste d'attente
Toute liste d'attente a les trois mêmes éléments : un formulaire sur votre page, un endroit qui stocke chaque inscription, et l'outil depuis lequel vous écrirez à la liste. Ici, le formulaire envoie à Ikiform. Ikiform vérifie le spam, stocke l'envoi, vous prévient par e-mail et envoie un webhook signé. Un petit relais ou un outil d'automatisation transmet l'adresse à votre outil d'e-mailing.
- Un visiteur saisit son e-mail sur votre landing page, et le formulaire l'envoie à votre endpoint Ikiform.
- Ikiform vérifie le honeypot, le score de spam et la limite de débit, puis stocke l'inscription.
- L'inscription apparaît dans le tableau de bord, où vous pouvez la rechercher et exporter la liste en CSV.
- Ikiform vous écrit, à vous et à votre équipe. Il n'écrit jamais à la personne qui s'est inscrite.
- Ikiform envoie un webhook submission.created signé.
- Un relais ou un outil d'automatisation ajoute l'adresse à votre outil d'e-mailing, qui envoie les éventuelles confirmations et, plus tard, l'e-mail de lancement.
Quatre façons de gérer une liste d'attente
Il n'y a pas d'outil idéal unique. Le tableau compare les quatre configurations courantes selon leurs points forts et ce qu'elles vous coûtent plus tard.
| Approche | Points forts | Compromis |
|---|---|---|
| SaaS de liste d'attente, comme LaunchList ou Waitlister | Liens de parrainage, classements et position dans la file, clés en main. Une page hébergée si vous n'avez pas encore de site. | Votre liste vit dans un autre produit. Le widget ressemble au leur sauf si vous payez ou le restylez. Le prix augmente généralement avec les inscriptions. |
| Formulaire intégré d'un form builder, comme Typeform ou Tally | Des parcours soignés à une question, sans aucun code. | Une iframe qui ne s'accorde pas à votre page et charge ses propres scripts. La marque, les limites de réponses et les webhooks dépendent de l'offre. |
| Votre propre base de données et route d'API | Contrôle total sur les données et toute logique, comme les codes de parrainage. | Vous construisez l'anti-spam, les limites de débit, les notifications et l'export, et il vous faut un serveur même si la landing page est statique. |
| Backend de formulaires, comme Ikiform | Votre propre HTML sur n'importe quel site. Filtrage du spam, stockage, alertes par e-mail, webhooks signés et export CSV, sans serveur. | Pas de classement de parrainage ni d'e-mails à la personne qui s'inscrit. Ceux-ci viennent de votre outil d'e-mailing. |
Choisissez un SaaS de liste d'attente si les mécaniques de parrainage sont tout votre plan. Choisissez un backend de formulaires si vous voulez que le formulaire ressemble au reste de votre site, garder une liste portable, et envoyer les e-mails depuis l'outil que vous utiliserez de toute façon après le lancement. La suite de ce guide utilise le backend de formulaires.
Le formulaire
Pour commencer, ne demandez qu'un e-mail. Chaque champ supplémentaire coûte des inscriptions, et vous pourrez poser des questions plus tard, par e-mail, à des personnes qui ont déjà dit oui. Choisissez votre stack ci-dessous ; ce choix s'applique à tous les exemples de code de ces guides.
Collez le formulaire là où l'inscription doit apparaître. Il envoie de façon classique puis redirige, il fonctionne donc sans aucun JavaScript.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="email">Email</label>
<input id="email" name="email" type="email" placeholder="you@company.com" autocomplete="email" required />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Join the waitlist</button>
</form>Pour garder les visiteurs sur la page, ajoutez ce script après le formulaire. Il envoie avec fetch, remplace le formulaire par la phrase de remerciement et affiche les erreurs de champ du serveur.
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);
const labels = {
email: "Email",
};
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceWith("You're on the list. We'll email you at launch.");
return;
}
const fields = Object.entries(result.error.fields ?? {});
status.textContent = fields.length
? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
: result.error.message;
} catch {
status.textContent = "Couldn't reach the server. Try again.";
}
button.disabled = false;
});Pour aller plus loin avec HTML, lisez Comment ajouter un formulaire de liste d'attente en HTML simple à n'importe quelle landing page.
Une server action transmet l'inscription avec votre clé secrète, et useActionState affiche le résultat sans rechargement de page. Le formulaire envoie quand même si JavaScript n'est pas encore chargé.
"use server";
import { headers } from "next/headers";
export interface JoinState {
ok: boolean;
message: string;
email?: string;
}
export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
const email = String(formData.get("email") ?? "").trim();
if (!email.includes("@")) return { ok: false, message: "Enter the email we should send your invite to.", 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,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return {
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
};
}"use client";
import { useActionState } from "react";
import { type JoinState, joinWaitlist } from "./actions";
const initial: JoinState = { ok: false, message: "" };
export function WaitlistForm() {
const [state, action, pending] = useActionState(joinWaitlist, initial);
if (state.ok) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required defaultValue={state.email} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Pour aller plus loin avec Next.js, lisez Comment créer une liste d'attente Next.js avec une server action.
Une Astro Action avec accept: "form" valide l'e-mail avec Zod et le transmet. Le formulaire envoie sans JavaScript côté client, la page doit donc être rendue à la demande : ajoutez un adaptateur et gardez prerender = false.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
joinWaitlist: defineAction({
accept: "form",
input: z.object({
email: z.email(),
ref: z.string().default("homepage"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) {
throw new ActionError({
code: "BAD_REQUEST",
message: result.error.fields?.email?.[0] ?? result.error.message,
});
}
return { joined: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.joinWaitlist);
---
{result?.data?.joined ? (
<p role="status">You're on the list. We'll email you on launch day.</p>
) : (
<form method="POST" action={actions.joinWaitlist}>
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Enter a valid email address." : result.error.message}</p>
)}
<button>Join the waitlist</button>
</form>
)}Pour aller plus loin avec Astro, lisez Créer une liste d'attente avec Astro.
Une fonction POST createServerFn reçoit le FormData, le vérifie dans .validator() et le transmet depuis le serveur. La route l'appelle via useServerFn.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const joinWaitlist = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) throw new Error("Enter the email we should send your invite to.");
return {
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
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, message: "You're on the list. We'll email you on launch day." };
return { ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) };
});
export const Route = createFileRoute("/")({ component: Home });
function Home() {
const join = useServerFn(joinWaitlist);
const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setState(await join({ data }));
} catch (error) {
setState({ ok: false, message: error instanceof Error ? error.message : "Couldn't join the list. Try again." });
} finally {
setPending(false);
}
}
if (state?.ok) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state?.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Pour aller plus loin avec TanStack Start, lisez Créer une liste d'attente avec TanStack Start.
L'action de la route transmet l'envoi, et actionData renvoie le résultat au composant. data() conserve le code de statut d'Ikiform en cas d'erreur.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/home";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
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,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return data(
{ ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}
export default function Home({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.ok) return <p role="status">{actionData.message}</p>;
return (
<Form method="post">
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</Form>
);
}Pour aller plus loin avec React Router, lisez Créer une liste d'attente avec React Router.
Une form action par défaut transmet l'envoi et renvoie fail() avec le message en cas d'erreur. use:enhance envoie sans rechargement et garde l'e-mail saisi dans l'input. Dans SvelteKit 3, 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 = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) return fail(400, { email, message: "Enter the email we should send your invite to." });
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,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
return fail(response.status, {
email,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
</script>
{#if form?.joined}
<p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update();
pending = false;
};
}}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" required value={form?.email ?? ""} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{form.message}</p>
{/if}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
{/if}Pour aller plus loin avec SvelteKit, lisez Créer une liste d'attente avec SvelteKit.
Une route serveur garde la clé secrète hors du navigateur. Définissez NUXT_IKIFORM_SECRET_KEY dans l'environnement, et Nuxt en remplit runtimeConfig.ikiformSecretKey.
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; ref?: string; _gotcha?: string }>(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() ?? "",
ref: body.ref ?? "homepage",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/waitlist", {
method: "POST",
body: { email: email.value, ref: "homepage", _gotcha: trap.value },
});
status.value = "joined";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else @submit.prevent="join">
<label for="email">Email</label>
<input id="email" v-model="email" name="email" type="email" required placeholder="you@company.com" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>Pour aller plus loin avec Nuxt, lisez Créer une liste d'attente avec Nuxt.
Une application monopage n'a pas de serveur à elle : le formulaire envoie donc directement à Ikiform depuis le navigateur. Ne mettez jamais de clé secrète dans ce code ; c'est Allowed domains qui protège le formulaire.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function WaitlistForm() {
const [status, setStatus] = useState<"idle" | "sending" | "joined">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setStatus("joined");
return;
}
setError(result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
}
setStatus("idle");
}
if (status === "joined") return <p role="status">You're on the list. We'll email you on launch day.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Le même envoi côté navigateur dans un composant monofichier Vue. Le formulaire garde son action, il fonctionne donc même si le script ne se charge pas.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "joined";
return;
}
error.value = result.error.fields?.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="join">
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>- L'endpoint est l'URL de votre formulaire. Remplacez
frm_xxxxxxxxxxxxxxxxpar l'id visible dans le tableau de bord. name="email"est l'id du champ. Gardezemail: Ikiform l'utilise comme adresse de réponse des e-mails de notification, et le compare aux domaines d'e-mails jetables.- L'input masqué
_gotchaest le honeypot. Les humains ne le voient jamais. Les bots qui remplissent tous les inputs se signalent eux-mêmes comme spam, et les versions serveur le transmettent tel quel. - L'input masqué
refenregistre la provenance de l'inscription. Changez sa valeur selon la page ou la campagne. - Avec une définition de champs dans l'onglet Fields, un input qu'elle ne liste pas est refusé avec
422et une erreurunknown_field: listez doncrefen plus d'email, comme ci-dessous. - Un envoi HTML classique redirige le navigateur après succès. Réglez Redirect after submit dans les réglages du formulaire sur votre propre page de remerciement, sinon Ikiform affiche la sienne.
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Les versions serveur transmettent l'en-tête Origin du navigateur, car Ikiform s'en sert pour repérer les tests gratuits depuis localhost et le fetch d'un serveur n'en envoie aucun.
Les guides par framework de la documentation vont plus loin : Next.js, Astro, SvelteKit, Nuxt, React, Vue et HTML simple. Le SDK encapsule la requête du navigateur dans submit(), et le modèle de formulaire de liste d'attente génère la version à quatre champs.
Les champs qui valent la peine
L'e-mail est le seul champ nécessaire. Si vous voulez trier la liste avant le lancement, ajoutez-en deux au plus. Essayez la version à quatre champs ci-dessous : elle exécute le validateur d'Ikiform dans votre navigateur et montre ce qui serait stocké, ou le 422 que vous recevriez.
Aperçu
Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.
ref, que votre page remplit avec un input masqué plutôt qu'un champ visible. Collez-la dans l'onglet Fields du formulaire et Ikiform l'applique côté serveur.| Champ | Pourquoi le demander | À garder en tête |
|---|---|---|
email | La liste elle-même | Obligatoire. Utilisez type="email" pour que les téléphones affichent le bon clavier |
name | Un e-mail de lancement personnalisé | Facultatif. Un prénom suffit |
role | Inviter d'abord les fondateurs, ou écrire un e-mail différent aux ingénieurs | Un select garde les réponses propres pour le filtrage |
source | Savoir quel canal fonctionne avant d'y investir | Le texte libre est sincère mais brouillon. Un input masqué ref est plus propre, voir ci-dessous |
ref | Compter les inscriptions par page ou campagne sans rien demander | Un input masqué. text facultatif avec max_length à 40 |
Pour suivre la provenance des inscriptions sans la demander, ajoutez un input masqué par page ou par campagne. Les noms qui commencent par _ sont des instructions pour Ikiform et ne sont jamais stockés : utilisez donc un nom simple comme ref :
<input type="hidden" name="ref" value="homepage" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />Chaque soumission enregistre aussi l'origine et le référent de la page, vous voyez donc de quelle page vient une inscription même sans ref. L'input _redirect envoie le navigateur vers votre propre page de remerciement ; il ne fonctionne que pour une URL https: sur l'un des Allowed domains du formulaire.
true avec l'horodatage de la soumission, ce qui enregistre le moment où la personne a accepté. Le modèle de newsletter en contient une.Tenir les bots hors de la liste
Un formulaire public avec un champ e-mail attire les bots en quelques jours. Les fausses inscriptions gonflent vos chiffres et, pire, votre e-mail de lancement atterrit dans des pièges à spam qui nuisent à votre réputation d'expéditeur. Ikiform superpose quatre défenses ; les deux premières sont actives sans configuration.
| Défense | Ce qu'elle arrête | Configuration |
|---|---|---|
| Honeypot | Les bots simples qui remplissent tous les inputs | L'input masqué _gotcha du formulaire ci-dessus |
| Score de spam | Adresses jetables (+2), répétitions identiques (+3), liens et formules de spam connues. À 5, une inscription est stockée comme spam | Aucune |
| Limites de débit | Les scripts qui envoient en boucle | Activée par défaut : Standard, 5 par minute et par visiteur, et 300 par minute pour l'ensemble du formulaire |
| Vérification humaine | Les bots qui pilotent un vrai navigateur | Réglez Spam check sur Ikiform. Cela exécute Cloudflare Turnstile sans clés à gérer |
Le spam est stocké avec une réponse de succès normale, pour que le bot n'apprenne rien. Il n'envoie ni e-mail ni webhook et ne compte pas dans votre consommation. Consultez le filtre Spam du tableau de bord une fois par semaine le premier mois ; si une vraie inscription s'y retrouve, marquez-la comme non-spam et son webhook part à ce moment-là.
Ajoutez aussi votre domaine dans Allowed domains. Les envois depuis d'autres sites sont alors marqués comme spam, ou refusés avec 403 origin_not_allowed si vous choisissez Reject. Avec la vérification humaine activée, le formulaire HTML n'a besoin d'aucune modification. La version fetch doit d'abord demander un jeton avec challenge() de @ikiform/sdk ; la documentation anti-spam montre les quatre lignes.
La même personne, deux fois
Les gens se réinscrivent depuis un autre onglet, un second appareil, ou une semaine plus tard quand ils ont oublié. Ikiform stocke chaque envoi accepté et ne fusionne ni ne rejette les répétitions par e-mail. Voici ce qui arrive à une répétition :
| Cas | Résultat |
|---|---|
| Même e-mail, champs différents ou plus de 24 heures plus tard | Une seconde soumission normale |
| Les mêmes champs à nouveau dans les 24 heures | +3 au score de spam, sous 5 : donc une seconde soumission normale |
| Une répétition identique depuis un domaine d'e-mail jetable | +3 et +2, ce qui atteint 5 : stockée comme spam |
Attendez-vous donc à quelques doublons, et supprimez-les là où la liste aboutit. Trois endroits conviennent, du moins au plus laborieux :
- Dans l'outil d'e-mailing. La plupart des outils d'e-mailing identifient les contacts par adresse : ajouter deux fois la même adresse met à jour un seul contact au lieu d'en créer deux.
- À l'export. Supprimez les e-mails en double du CSV avant de l'importer où que ce soit. Deux méthodes sont détaillées ci-dessous.
- Dans votre relais. Passez l'adresse en minuscules et retirez les espaces, puis insérez-la avec une contrainte d'unicité ou un upsert, pour qu'une répétition soit sans effet.
Vous voulez empêcher un double clic de créer deux lignes ? Envoyez une clé d'idempotence par chargement de page avec la version fetch. Cela arrête les répétitions accidentelles ; cela n'empêche pas une personne de s'inscrire deux fois exprès.
Confirmer les inscriptions
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. C'est voulu : l'outil qui envoie votre e-mail de lancement doit aussi envoyer la confirmation, pour que désinscriptions et erreurs de remise soient suivies au même endroit.
Deux façons d'y connecter Ikiform :
- Sans code. Ajoutez un endpoint de webhook qui pointe vers un outil d'automatisation, puis utilisez son action pour votre outil d'e-mailing. Voir Zapier, Make ou n8n.
- Un petit relais. Un route handler sur n'importe quel hébergeur Node vérifie la signature et appelle l'API de votre outil d'e-mailing. L'exemple ci-dessous est une route Next.js ; le même code tourne dans un Cloudflare Worker.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const email = String(event.data.fields.email ?? "").trim().toLowerCase();
if (!email) return new Response(null, { status: 204 });
const response = await fetch(process.env.LIST_API_URL!, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.LIST_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
email,
name: event.data.fields.name ?? null,
tags: ["waitlist", String(event.data.fields.role ?? "unknown")],
}),
});
if (response.status === 409) return new Response(null, { status: 204 });
if (!response.ok) return new Response(`List answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}verifyWebhookvérifie la signature et l'horodatage, pour que personne d'autre ne puisse ajouter d'adresses via cette URL.- Remplacez le
fetchpar l'appel d'ajout de contact de votre outil d'e-mailing. La plupart prennent un e-mail et une liste ou un tag, et envoient leur propre e-mail de confirmation quand le double opt-in est activé dans l'outil. - Répondre
204indique à Ikiform que la livraison a réussi. Un502fait réessayer Ikiform, avec le mêmewebhook-id, donc une courte panne de votre outil d'e-mailing ne fait perdre aucune inscription.
Ajoutez l'endpoint sous Webhooks dans le tableau de bord et copiez son secret whsec_ dans IKIFORM_WEBHOOK_SECRET. Un espace de travail peut avoir 3 endpoints. La documentation des webhooks couvre les nouvelles tentatives et le payload, et votre propre serveur propose une version qui stocke chaque livraison une seule fois.
Le jour du lancement
- La page est mise en ligne avec le formulaire qui envoie à Ikiform.
- Chaque semaine, partagez la page et lisez les nouvelles inscriptions et leurs réponses.
- Une semaine avant le lancement, exportez la liste en CSV, supprimez les doublons et segmentez-la par rôle.
- Le jour du lancement, envoyez un seul e-mail à la liste depuis votre outil d'e-mailing.
Exporter la liste
Ouvrez Submissions dans le tableau de bord, choisissez le formulaire de liste d'attente, réglez le statut sur Received et la période sur All time, puis choisissez Export CSV. Les colonnes sont id, created_at, form et status, puis une colonne par champ. Le fichier est en UTF-8 avec BOM, les accents survivent donc à Excel. Un export contient jusqu'à 100 000 lignes ; au-delà, paginez via l'API REST.
Pour supprimer les doublons dans Google Sheets, importez le CSV et utilisez cette formule dans une feuille vide, avec l'e-mail en colonne E :
=SORTN(A2:F, 9^9, 2, LOWER(TRIM(E2:E)), TRUE)Elle garde une ligne par adresse, sans tenir compte de la casse ni des espaces, triée par e-mail. Ou lancez ceci avec Node dans le dossier qui contient l'export. Le script garde la première ligne de chaque adresse et suppose qu'il n'y a pas de virgule dans les valeurs, ce qui est vrai pour un formulaire qui ne demande que l'e-mail :
import { readFileSync, writeFileSync } from "node:fs";
const [header = "", ...rows] = readFileSync("submissions.csv", "utf8").replace(/^\uFEFF/, "").trim().split(/\r?\n/);
const columns = header.split(",");
const emailAt = columns.indexOf("email");
const seen = new Set();
const unique = rows.filter((row) => {
const email = row.split(",")[emailAt]?.trim().toLowerCase();
if (!email || seen.has(email)) return false;
seen.add(email);
return true;
});
writeFileSync("waitlist.csv", [header, ...unique].join("\n"));
console.log(`${rows.length} rows, ${unique.length} unique emails`);Envoyer l'e-mail de lancement
Envoyez-le depuis votre outil d'e-mailing, pas depuis votre propre boîte. Une boîte personnelle qui écrit à des centaines d'adresses d'un coup ressemble à du spam pour les fournisseurs de messagerie, et vous aurez besoin de gérer les désinscriptions dès l'envoi. Faites court :
Subject: Acme is live. You're in.
Hi Ada,
You joined the Acme waitlist in the spring. Today it's open.
Sign in here: https://acme.example/start
You get the first 30 days free because you waited.
Reply to this email if anything breaks. It comes to me.
Ada, founder of Acme- Dites ce qui est en ligne et mettez un lien direct vers la page d'inscription ou de connexion.
- Récompensez l'attente, par exemple avec un essai plus long ou une réduction, et dites-le une fois.
- Envoyez-le au nom d'une personne. Les réponses sont les meilleurs retours que vous aurez au début.
- Adressez des premières lignes différentes aux fondateurs et aux ingénieurs si vous avez collecté
role. C'est la raison de le collecter.
Choisissez votre cas
Les étapes ci-dessus fonctionnent partout. Ces pages couvrent ce qui change pour un lancement ou un créateur de sites en particulier.
- Liste de lancement Product HuntCollectez une liste des semaines avant Product Hunt, taguez chaque inscription par canal et envoyez l'e-mail du jour J en deux vagues calées sur l'heure du Pacifique.
- Inscription bêta avec sélectionUn formulaire de candidature bêta avec des questions de sélection appliquées côté serveur, et un script qui note les candidats et choisit chaque cohorte.
- Liste d'attente d'application mobileUne liste d'attente avant lancement pour une app iOS ou Android qui demande le téléphone utilisé, envoie les testeurs iPhone vers TestFlight et remplit un test fermé Play.
- Liste d'attente Next.jsUne liste d'attente Next.js App Router : une server action qui transmet avec une clé secrète, useActionState, un merci optimiste et une limite par visiteur.
- Liste d'attente sur FramerDeux façons de collecter des inscriptions sur Framer : le formulaire Framer avec un webhook vers Ikiform, ou un code component qui garde limites et anti-spam.
- Liste d'attente sur WebflowFaites pointer un Form Block Webflow vers Ikiform, taguez les inscriptions par source avec un champ masqué et gardez l'état de succès de Webflow avec un court script.
- Liste d'attente sur CarrdCollectez des inscriptions sur une page Carrd : un formulaire Send to URL sur Pro Plus avec suivi UTM, ou un formulaire HTML intégré avec honeypot sur Pro Standard.
- Liste d'attente en HTML simpleUne page de liste d'attente complète en HTML simple, sans build, qui fonctionne sans JavaScript, remercie sur place quand il se charge, et se replie si besoin.
- Liste d'attente AstroUne liste d'attente Astro sur une landing page statique : une Action transmet chaque inscription, un script l'appelle, une page à la demande gère le sans-JS.
- Liste d'attente TanStack StartUne liste d'attente TanStack Start : une server route répond à un envoi classique comme à fetch, et validateSearch garde la source et le résultat dans l'URL.
- Liste d'attente React RouterUne liste d'attente React Router en pied de chaque page : un fetcher envoie à une seule action, tague chaque inscription avec sa page et fonctionne sans JS.
- Liste d'attente SvelteKitUne liste d'attente SvelteKit 3 : un hook garde le premier ?ref= dans un cookie, une action nommée transmet l'inscription, les visiteurs qui reviennent voient qu'ils sont inscrits.
- Liste d'attente NuxtUne liste d'attente Nuxt sur une landing page pré-rendue : une route Nitro accepte $fetch et les envois classiques, valide avec Zod et redirige sans JavaScript.
Vérifiez que tout fonctionne
Faites ces vérifications avant de partager la 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. - Remplissez le formulaire sur votre page et envoyez-le. Vous arrivez sur votre page de remerciement, ou sur celle hébergée par Ikiform.
- Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés.
- Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et Reply répond à l'expéditeur quand le formulaire a un champ
email. - Si vous avez ajouté un webhook, ouvrez l'endpoint dans Webhooks. La livraison affiche
succeededavec la réponse de votre serveur. - 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.
- Inscrivez-vous deux fois avec la même adresse et vérifiez que votre outil d'e-mailing n'affiche toujours qu'un seul contact.
Dépannage
| Problème | Que faire |
|---|---|
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. |
403 challenge_required | Le spam check du formulaire est réglé sur Ikiform et un envoi fetch n'avait pas de jeton. Obtenez-en un avec challenge(formId) de @ikiform/sdk avant chaque envoi, ou désactivez la vérification. |
| 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. |
| 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. |
| Le même e-mail apparaît deux fois | C'est normal. Ikiform ne dédoublonne pas par e-mail. Supprimez les répétitions à l'export, ou faites un upsert par e-mail dans votre relais ou votre outil d'e-mailing. |
| Les inscriptions arrivent dans Ikiform mais pas dans l'outil d'e-mailing | Ouvrez l'endpoint sous Webhooks et lisez le journal des livraisons. Un 400 signifie que le secret est faux ; un 502 avec le statut de votre outil signifie que l'outil a refusé le contact. |
402 test_limit_reached | L'espace de travail n'a pas d'offre et a utilisé ses 50 tests gratuits depuis localhost ce mois-ci. Choisissez une offre, ou utilisez Send test dans le tableau de bord. |
Questions
Pas d'un serveur à vous. Le formulaire a besoin d'un endroit où envoyer, et c'est le rôle d'un backend de formulaires : faites pointer l'action du formulaire vers votre endpoint Ikiform, qui stocke, filtre et transmet chaque inscription. Votre landing page peut rester statique.
Non. Ikiform écrit à vous et à votre équipe, pas à la personne qui s'inscrit : il n'y a donc ni e-mail de confirmation ni double opt-in. Envoyez chaque inscription par webhook à votre outil d'e-mailing et activez-y le double opt-in.
Gardez le honeypot masqué _gotcha, laissez la limite de débit par défaut, ajoutez votre domaine dans Allowed domains, et réglez la vérification anti-spam sur Ikiform pour une vérification Cloudflare Turnstile sans clés à gérer. Les adresses jetables augmentent aussi le score de spam.
C'est possible. Ikiform conserve chaque envoi accepté et ne fusionne pas les répétitions par e-mail. Dédoublonnez là où vit la liste : votre outil d'e-mailing, une colonne unique dans votre base de données, ou le CSV avant de l'importer.
Pas avec Ikiform seul. Il n'a ni liens de parrainage ni classements. Vous pouvez passer un code de parrainage dans un champ masqué et le compter vous-même, ou utiliser un produit de liste d'attente bâti autour du parrainage si c'est central pour votre lancement.
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.