Formulaire de capture de leads6 min de lecture
Créer un formulaire de demande de démo qui fait réserver l'appel
Un formulaire de réservation de démo qui stocke le lead, puis envoie le visiteur vers Cal.com ou Calendly avec son nom et son e-mail déjà remplis.
Envoyez d'abord la demande de démo à un backend de formulaires, pour que le lead soit stocké et votre équipe prévenue même si le visiteur ne choisit jamais de créneau. Envoyez ensuite le navigateur vers votre page de réservation avec name et email dans l'URL, que Calendly et Cal.com utilisent pour pré-remplir leur formulaire. Dirigez les petites équipes ailleurs, par exemple vers une visite enregistrée, pour que votre agenda contienne des appels qui peuvent se conclure.
Ce qui change avec une demande de démo
Quelqu'un qui demande une démo veut un créneau dans un agenda, pas un échange d'e-mails. Chaque aller-retour du type « mardi, ça vous va ? » est une occasion de réserver chez un concurrent. La mission du formulaire est donc courte : recueillir de quoi qualifier, puis mettre un agenda devant la personne tant que l'onglet est encore ouvert.
Trois contraintes guident la construction :
- Le lead doit être stocké avant l'ouverture de l'outil de réservation. Beaucoup de visiteurs ouvrent la page de réservation et la ferment. Si la seule trace d'eux est dans l'outil de réservation, ces leads sont perdus. Envoyez d'abord à Ikiform et ne redirigez qu'en cas de succès.
_redirectne peut pas transporter ce qu'ils ont tapé. C'est une URL fixe, qu'Ikiform ne suit que pour une URLhttps:sur l'un de vos Allowed domains. Y ajoutercal.comoucalendly.comlaisserait aussi passer les envois depuis ces sites. Construisez plutôt le lien de réservation dans votre propre code.- Toutes les demandes ne méritent pas un appel en direct. Un fondateur solo qui demande une démo d'un produit d'équipe sera mieux servi par une visite guidée et un lien d'inscription. Une question à choix fixe décide où va ensuite le navigateur.
- Le visiteur remplit le nom, l'e-mail professionnel, l'entreprise et la taille de l'équipe, et le formulaire envoie à Ikiform.
- Ikiform stocke la demande et écrit à votre équipe. Rien d'autre ne se passe tant qu'il n'a pas répondu ok.
- Votre code lit la taille d'équipe choisie par le visiteur.
- Les équipes de 11 personnes ou plus vont vers la page de réservation, avec le nom et l'e-mail dans l'URL.
- Les équipes de 1 à 10 personnes vont vers une visite enregistrée avec un lien d'inscription.
Quatre champs, dont un pour le routage
| Champ | Type | Pourquoi |
|---|---|---|
name | text | Pré-remplit le formulaire de réservation, pour que le visiteur ne le tape qu'une fois |
email | Pré-remplit le formulaire de réservation et devient l'adresse de réponse de l'e-mail de notification | |
company | text, obligatoire ici | Votre commercial le consulte avant l'appel |
team_size | select | Décide entre page de réservation et visite, et arrive sous la forme d'une valeur parmi quatre connues |
Gardez le cas d'usage, le calendrier et le numéro de téléphone pour l'appel lui-même. L'outil de réservation peut poser une question à lui si vous avez besoin de contexte avant le rendez-vous, et Calendly comme Cal.com peuvent aussi pré-remplir des questions personnalisées.
Envoyer, puis passer la main
Chaque version envoie la demande, attend la réponse d'Ikiform et seulement ensuite construit l'URL suivante. Les exemples utilisent Cal.com ; pour Calendly, remplacez par le lien de votre événement. Calendly documente name et email comme paramètres de pré-remplissage, et Cal.com pré-remplit tout champ de réservation par son identifiant, dont name et email.
Le formulaire et son script ensemble. Sans JavaScript, le formulaire envoie quand même et arrive sur votre page Redirect after submit : placez-y donc le lien de réservation comme solution de repli.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" id="demo-form">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" required />
<label for="team_size">Team size</label>
<select id="team_size" name="team_size" required>
<option value="1_10">1 to 10</option>
<option value="11_50">11 to 50</option>
<option value="51_200">51 to 200</option>
<option value="201_plus">201 or more</option>
</select>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p role="alert" id="demo-error" hidden></p>
<button>Pick a time</button>
</form>
<script>
const BOOKING = "https://cal.com/acme/demo";
const SELF_SERVE = "/tour";
const form = document.getElementById("demo-form");
const error = document.getElementById("demo-error");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
form.querySelector("button").disabled = true;
error.hidden = true;
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: data,
});
const result = await response.json();
if (!result.ok) throw new Error(Object.values(result.error.fields ?? {})[0]?.[0] ?? result.error.message);
const small = data.get("team_size") === "1_10";
const next = new URL(small ? SELF_SERVE : BOOKING, location.href);
next.searchParams.set("name", String(data.get("name")));
next.searchParams.set("email", String(data.get("email")));
location.assign(next);
} catch (failure) {
error.textContent = failure instanceof Error ? failure.message : "Couldn't send. Try again.";
error.hidden = false;
form.querySelector("button").disabled = false;
}
});
</script>La server action appelle redirect() avec l'URL de réservation une fois qu'Ikiform a répondu ok. Next.js y accepte les URL absolues, et le formulaire fonctionne avant l'hydratation, car une server action répond à un envoi de formulaire classique par un 303. Gardez redirect() hors de tout bloc try, puisqu'il fonctionne en levant une exception.
"use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
const BOOKING = "https://cal.com/acme/demo";
export async function requestDemo(_previous: string, formData: FormData): Promise<string> {
const lead = {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
company: String(formData.get("company") ?? "").trim(),
team_size: String(formData.get("team_size") ?? ""),
_gotcha: String(formData.get("_gotcha") ?? ""),
};
const origin = (await 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 ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(lead),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
return Object.values(fields)[0]?.[0] ?? result?.error?.message ?? "Couldn't send. Try again.";
}
const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : BOOKING);
next.searchParams.set("name", lead.name);
next.searchParams.set("email", lead.email);
redirect(next.toString());
}"use client";
import { useActionState } from "react";
import { requestDemo } from "./actions";
export function DemoForm() {
const [error, action, pending] = useActionState(requestDemo, "");
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required />
<label htmlFor="team_size">Team size</label>
<select id="team_size" name="team_size" required>
<option value="1_10">1 to 10</option>
<option value="11_50">11 to 50</option>
<option value="51_200">51 to 200</option>
<option value="201_plus">201 or more</option>
</select>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</button>
</form>
);
}La form action renvoie fail() avec le premier message en cas d'erreur et appelle redirect(303, url, { external: true }) en cas de succès ; SvelteKit 3 refuse une redirection vers un autre site sans cette option. Déclarez IKIFORM_SECRET_KEY dans src/env.ts avec defineEnvVars pour que $app/env/private puisse la lire. Associez-la à n'importe quel formulaire +page.svelte qui envoie à l'action par défaut ; sans JavaScript, le navigateur suit lui-même le 303.
import { fail, redirect } 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 lead = Object.fromEntries(
["name", "email", "company", "team_size", "_gotcha"].map((key) => [key, String(data.get(key) ?? "").trim()]),
);
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(lead),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? {};
return fail(response.status, { lead, message: Object.values(fields)[0]?.[0] ?? result.error.message });
}
const next = new URL(lead.team_size === "1_10" ? "https://acme.example/tour" : "https://cal.com/acme/demo");
next.searchParams.set("name", lead.name);
next.searchParams.set("email", lead.email);
redirect(303, next.toString(), { external: true });
},
} satisfies Actions;Dans une single-page app, le navigateur envoie directement à Ikiform et poursuit avec location.assign. Utilisez Allowed domains pour protéger le formulaire, jamais une clé secrète.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const BOOKING = "https://cal.com/acme/demo";
export function DemoForm() {
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: data });
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? {};
setError(Object.values(fields)[0]?.[0] ?? result.error.message);
setPending(false);
return;
}
const next = new URL(data.get("team_size") === "1_10" ? "/tour" : BOOKING, window.location.href);
next.searchParams.set("name", String(data.get("name")));
next.searchParams.set("email", String(data.get("email")));
window.location.assign(next);
} catch {
setError("Couldn't reach the server. Try again.");
setPending(false);
}
}
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required />
<label htmlFor="team_size">Team size</label>
<select id="team_size" name="team_size" required>
<option value="1_10">1 to 10</option>
<option value="11_50">11 to 50</option>
<option value="51_200">51 to 200</option>
<option value="201_plus">201 or more</option>
</select>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={pending}>{pending ? "Finding times" : "Pick a time"}</button>
</form>
);
}IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur. Les versions serveur transmettent l'en-tête Origin du visiteur, car un fetch côté serveur n'en envoie aucun, et c'est grâce à lui qu'Ikiform détecte les tests gratuits depuis localhost.E-mail professionnel, sans bloquer Gmail
Ikiform n'a pas de réglage qui refuse les fournisseurs d'e-mail gratuits. L'option allowed_domains du champ e-mail est une liste d'autorisation : utile pour un formulaire interne limité au domaine de votre propre entreprise, inadaptée à un formulaire de démo public. Ce n'est pas un problème, car bloquer Gmail bloque aussi les fondateurs, les consultants et les acheteurs qui remplissent des formulaires depuis leur téléphone.
Étiquetez plutôt ces demandes, là où votre équipe les voit. Cette fonction s'exécute dans le relais qui publie chaque demande dans le chat ou votre CRM, et donne un indice au commercial avant l'appel :
const FREE_MAIL = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "proton.me"]);
export function demoTag(fields: Record<string, unknown>) {
const domain = String(fields.email ?? "").split("@")[1]?.toLowerCase() ?? "";
const size = String(fields.team_size ?? "");
if (FREE_MAIL.has(domain)) return "personal address, check the company";
if (size === "51_200" || size === "201_plus") return "priority";
return "standard";
}Appelez-la avec event.data.fields après verifyWebhook, puis ajoutez l'étiquette au message de chat ou à une propriété du CRM. Les leads vers HubSpot ou Pipedrive montre un relais complet.
Vérifiez que tout fonctionne
- Ajoutez le domaine de votre site et
localhostsous Allowed domains. - Envoyez avec la taille d'équipe 51 à 200. La page de réservation s'ouvre avec votre nom et votre e-mail remplis.
- Fermez la page de réservation sans choisir de créneau. La demande est toujours dans Submissions, et l'e-mail de notification est dans votre boîte.
- Envoyez à nouveau avec 1 à 10 et vérifiez que vous arrivez sur la visite guidée.
- Envoyez avec un e-mail invalide. L'erreur s'affiche sur le formulaire, et la page de réservation ne s'ouvre pas.
- Réservez un rendez-vous de test, puis vérifiez que la confirmation de l'outil de réservation arrive à l'adresse saisie.
Pièges
- Deux enregistrements par démo réservée. Ikiform contient la demande ; l'outil de réservation contient le rendez-vous. Rapprochez-les par e-mail dans votre CRM, pas par nom.
- Pas de confirmation de la part d'Ikiform. Ikiform n'envoie pas d'e-mail à la personne qui remplit le formulaire. La confirmation et l'invitation d'agenda de l'outil de réservation couvrent les appels réservés. Pour les demandes qui ne réservent jamais, répondez depuis l'e-mail de notification le jour même.
- Les paramètres d'URL sont visibles. Le nom et l'e-mail dans l'URL de réservation apparaissent dans l'historique du navigateur et dans les statistiques de l'outil de réservation. N'y passez rien d'autre.
- Les embeds. Si vous intégrez plutôt l'outil de réservation sur votre propre page
/book, Cal.com ne transmet les paramètres d'URL de votre page que siforwardQueryParamsest activé. Calendly documente le pré-remplissage des embeds via l'objetprefillde son embed JavaScript : utilisez-le plutôt qu'une simple iframe. - La vérification anti-spam. Avec Spam check réglé sur Ikiform, les versions navigateur doivent appeler
challenge()de@ikiform/sdkavant d'envoyer, sinon elles reçoivent403 challenge_required. Les versions serveur contournent la vérification.
Questions
Ajoutez-les aux paramètres d'URL du lien de réservation, sous la forme ?name=Ada%20Lovelace&email=ada%40example.com. Construisez l'URL dans votre propre code une fois que le backend de formulaires a répondu ok, et envoyez-y le navigateur.
Beaucoup de visiteurs ouvrent un outil de réservation et repartent sans réserver, et l'outil n'enregistre que les personnes qui réservent. Un court formulaire avant garantit que votre équipe a chaque demande, avec une adresse de réponse, et peut relancer ceux qui n'ont pas choisi de créneau.
Seulement vers une URL fixe sur l'un des domaines autorisés du formulaire, et elle ne peut pas inclure ce que le visiteur a tapé. Pour pré-remplir le formulaire de réservation, construisez plutôt l'URL dans le script de votre page ou dans votre server action.
Il n'y a pas de liste de blocage intégrée. L'option allowed domains du champ e-mail n'autorise que les domaines listés, ce qui convient aux formulaires internes. Acceptez toutes les adresses et étiquetez les demandes en messagerie gratuite dans votre relais ou votre CRM.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.