Formulaire de capture de leads8 min de lecture
Créer un formulaire de leads qualifiant avec Astro
Un formulaire de leads Astro construit sur les Actions : validation Zod, UTM du premier contact sur un site statique, une page de contact à la demande et une redirection vers votre outil de réservation.
Gardez vos pages marketing prérendues et enregistrez les tags UTM et le referrer de la première visite dans un petit cookie depuis un script client, car le middleware d'une page prérendue ne s'exécute qu'au build. Rendez la page de contact à la demande, validez le lead dans une Astro Action avec astro/zod, et transmettez-le avec l'attribution à votre backend de formulaires. Renvoyez l'URL suivante depuis l'action et appelez Astro.redirect() pour envoyer les leads qualifiés vers votre outil de réservation.
L'onglet Astro du guide principal envoie un lead via une action et affiche une ligne de remerciement. Cette page en fait un formulaire commercial : taille d'entreprise, budget et calendrier vérifiés par Zod sur votre serveur, des données de campagne qui survivent à un site statique, et un passage vers votre agenda pour les leads qui correspondent.
Ce que change Astro
Astro prérend les pages par défaut, et cela conditionne toute l'attribution. Le middleware s'exécute une seule fois pour une page prérendue, au build, sans visiteur, sans cookies et sans paramètres d'URL. La landing page où apparaît réellement ?utm_source= est presque toujours prérendue : le middleware ne voit donc jamais les tags.
Le premier contact est donc enregistré dans le navigateur, par un court script dans votre layout, et relu côté serveur par l'action. La page de contact est la seule qui doit être rendue à la demande, car la documentation d'Astro l'exige pour toute page qui appelle une action depuis un formulaire.
- Au build, le middleware s'exécute pour les pages prérendues sans données de requête : il ne peut donc pas enregistrer de visite.
- Dans le navigateur, le script FirstTouch lit les tags UTM et le referrer à la première page vue.
- Il les stocke une fois dans un cookie lead_source que portent toutes les requêtes suivantes.
- Le formulaire de contact envoie à l'action avec un simple formulaire HTML, sans JavaScript.
- L'action valide avec Zod, lit le cookie et transmet le lead à Ikiform.
- La page lit le résultat et redirige vers l'outil de réservation ou la page de remerciement ; les erreurs de saisie s'affichent sous leurs champs.
Mise en place du projet
Ajoutez un adaptateur avec npx astro add vercel (ou netlify, ou cloudflare), puis déclarez la clé avec astro:env pour qu'Astro refuse de builder sans elle et ne l'envoie jamais au navigateur. La page Astro de la documentation explique comment créer le formulaire et la clé.
src/
actions/index.ts
components/FirstTouch.astro
layouts/Base.astro
pages/
pricing.astro
contact-sales.astro
contact-sales/thanks.astro
astro.config.mjs
.envimport { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";
export default defineConfig({
adapter: vercel(),
env: {
schema: {
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
},
},
});Sans output défini, chaque page reste prérendue et seules les pages qui exportent prerender = false tournent sur le serveur. Ajoutez les champs ci-dessous dans l'onglet Fields du formulaire, pour qu'Ikiform vérifie les mêmes règles et rejette tout le reste.
| Id du champ | Type dans Ikiform | Renseigné par |
|---|---|---|
name, email, company | Texte, e-mail, texte | Le visiteur |
company_size | Select : 1_10, 11_50, 51_200, 201_1000, 1000_plus | Le visiteur |
budget | Select : under_10k, 10k_50k, 50k_plus, unsure | Le visiteur |
timeline | Select : this_quarter, next_quarter, later, researching | Le visiteur |
message | Textarea, facultatif | Le visiteur |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Texte, facultatif | Votre serveur, depuis la première visite |
fit | Select : qualified, nurture | Votre serveur, après validation |
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le premier contact sur un site statique
<script>
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];
if (!document.cookie.split("; ").some((pair) => pair.startsWith("lead_source="))) {
const params = new URLSearchParams(location.search);
const from = URL.canParse(document.referrer) ? new URL(document.referrer) : null;
const external = from && from.host !== location.host ? from.href.slice(0, 300) : "";
const source = new URLSearchParams();
for (const tag of TAGS) source.set(tag, (params.get(tag) ?? "").slice(0, 100));
if (external || TAGS.some((tag) => source.get(tag))) {
source.set("referrer", external);
source.set("landing_page", location.pathname.slice(0, 200));
document.cookie = `lead_source=${encodeURIComponent(source.toString())}; path=/; max-age=2592000; samesite=lax`;
}
}
</script>- Placez
<FirstTouch />dans le layout utilisé par toutes les pages. Astro bundle le script une fois et l'exécute à chaque chargement de page ; la vérification du cookie fait que seule la première visite compte. - Le cookie n'est pas
httpOnly, car c'est un script qui le pose. Considérez-le comme un indice : l'action ne lit que cinq clés connues et Ikiform limite la longueur de chaque champ. - Un referrer provenant de votre propre hôte est ignoré : passer de Pricing à Contact n'écrase donc pas la vraie source.
- Si vos landing pages sont plutôt rendues à la demande, la même logique tient dans
src/middleware.tsaveccontext.cookies.set(). Vérifiez d'abordcontext.isPrerenderedet passez votre chemin.
L'action du formulaire
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
export const server = {
submitLead: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().min(1, "Which company is this for?").max(200),
company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
message: z.string().trim().max(5000).nullish(),
_gotcha: z.string().nullish(),
}),
handler: async (lead, context) => {
const stored = new URLSearchParams(context.cookies.get("lead_source")?.value ?? "");
const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
const qualified =
lead.company_size !== "1_10" &&
["10k_50k", "50k_plus"].includes(lead.budget) &&
["this_quarter", "next_quarter"].includes(lead.timeline);
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 ${IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...lead, ...source, fit: qualified ? "qualified" : "nurture" }),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
throw new ActionError({
code: response.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
message: result?.error?.message ?? "We couldn't send that. Email sales@example.com instead.",
});
}
if (!qualified || lead._gotcha) return { next: "/contact-sales/thanks" };
return { next: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` };
},
}),
};- Avec
accept: "form", Astro transforme un input vide ennullavant que Zod ne le voie. Unselectsans choix échoue donc surz.enumavec votre propre message, et un texte facultatif a besoin denullish(). - L'en-tête
Origindu navigateur est transmis, car un fetch côté serveur n'en envoie aucun, et c'est grâce à lui qu'Ikiform détecte les tests gratuits depuislocalhost. Dansastro dev, c'esthttp://localhost:4321. - L'action renvoie l'endroit où aller ensuite au lieu de rediriger elle-même. Les actions ne peuvent pas appeler
redirect; c'est la page qui le fait avec le résultat. - Le
429d'Ikiform devientTOO_MANY_REQUESTS, pour que la page distingue un formulaire saturé d'une saisie invalide.
La page de contact
---
import { actions, isInputError } from "astro:actions";
import Base from "../layouts/Base.astro";
export const prerender = false;
const result = Astro.getActionResult(actions.submitLead);
if (result && !result.error) return Astro.redirect(result.data.next, 303);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
const failure = result?.error && !isInputError(result.error) ? result.error.message : "";
---
<Base title="Talk to sales">
<form method="POST" action={actions.submitLead}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" required />
{fields.company && <p role="alert">{fields.company[0]}</p>}
<label for="company_size">Company size</label>
<select id="company_size" name="company_size" required>
<option value="">Choose one</option>
<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_1000">201 to 1,000</option>
<option value="1000_plus">More than 1,000</option>
</select>
{fields.company_size && <p role="alert">{fields.company_size[0]}</p>}
<label for="budget">Budget</label>
<select id="budget" name="budget" required>
<option value="">Choose one</option>
<option value="under_10k">Under $10k</option>
<option value="10k_50k">$10k to $50k</option>
<option value="50k_plus">More than $50k</option>
<option value="unsure">Not sure yet</option>
</select>
{fields.budget && <p role="alert">{fields.budget[0]}</p>}
<label for="timeline">When do you want to start?</label>
<select id="timeline" name="timeline" required>
<option value="">Choose one</option>
<option value="this_quarter">This quarter</option>
<option value="next_quarter">Next quarter</option>
<option value="later">Later this year</option>
<option value="researching">Researching for now</option>
</select>
{fields.timeline && <p role="alert">{fields.timeline[0]}</p>}
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{failure && <p role="alert">{failure}</p>}
<button>Talk to sales</button>
</form>
</Base>
<script>
const form = document.querySelector("form");
form?.addEventListener("submit", () => {
const button = form.querySelector("button");
if (button) {
button.disabled = true;
button.textContent = "Sending";
}
});
</script>Le formulaire envoie à la manière HTML classique : il fonctionne donc avec JavaScript désactivé. Astro exécute l'action, affiche cette même page avec le résultat, et le frontmatter redirige avec un 303 ou affiche les erreurs. isInputError distingue les messages par champ de Zod des échecs propres à l'action. Le petit script ne fait que désactiver le bouton, pour qu'un double clic ne puisse pas envoyer le lead deux fois.
Honeypot et vérification humaine
Le honeypot fait partie du schéma et est transmis : Ikiform stocke donc un honeypot rempli comme spam, et l'action envoie vers la page de remerciement plutôt que vers votre agenda. La clé secrète contourne la vérification humaine d'Ikiform. Si des bots trouvent la page, vérifiez un token Turnstile dans le handler, comme dans Turnstile sur un formulaire.
Notes de déploiement
| Hébergeur | À savoir |
|---|---|
| Vercel | @astrojs/vercel exécute la page de contact et les actions comme des fonctions et sert le reste en fichiers statiques. Ajoutez IKIFORM_SECRET_KEY dans les variables d'environnement du projet. |
| Netlify | @astrojs/netlify fait de même avec les Netlify Functions. Définissez la clé dans les variables d'environnement du site. |
| Cloudflare | @astrojs/cloudflare exécute les pages à la demande dans un Worker. Stockez la clé comme secret avec wrangler secret put ; astro:env la lit à l'exécution. |
Vérifiez que tout fonctionne
Les envois depuis astro dev portent une origine localhost. 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.
- Lancez
astro dev, ouvrez/pricing?utm_source=test&utm_campaign=launchet vérifiez le cookielead_sourcedans les outils de développement. - Allez sur
/contact-sales, laissez Budget vide et envoyez avec JavaScript désactivé. La page se recharge avec l'erreur de budget. - Envoyez comme un lead qualifié : 11 à 50 personnes, plus de 50 k$, le trimestre prochain. Vous arrivez sur Cal.com avec le nom et l'e-mail remplis.
- Envoyez avec Researching for now. Vous arrivez sur
/contact-sales/thanks. - Ouvrez Submissions dans le tableau de bord et vérifiez
utm_source,referrer,landing_pageetfitsur les deux leads. - 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.
Pièges
| Problème | Que faire |
|---|---|
| Le formulaire envoie et la page n'affiche rien de nouveau | La page de contact est prérendue. Ajoutez-y export const prerender = false ; la documentation d'Astro exige le rendu à la demande pour les actions de formulaire. |
| Tous les champs sont vides après une erreur | Astro vide les inputs lors d'un envoi de formulaire. Pour les garder, activez les view transitions avec <ClientRouter /> et ajoutez transition:persist à chaque input. |
| Les champs UTM sont vides alors que le lien avait des tags | Le cookie existait déjà depuis une visite précédente, ou le layout avec <FirstTouch /> n'est pas utilisé sur la landing page. Effacez le cookie et réessayez. |
IKIFORM_SECRET_KEY is missing au build | astro:env valide le schéma. Définissez la variable dans .env en local et dans les réglages de votre hébergeur. |
422 validation_failed | L'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ. |
Questions
Oui, mais pas dans le middleware, qui s'exécute au build pour les pages prérendues. Un petit script dans votre layout lit les tags et le referrer à la première visite et les stocke dans un cookie, que l'action lit quand le lead envoie le formulaire.
Oui. Un formulaire avec method POST et action réglée sur l'action envoie à la manière HTML classique. La page doit être rendue à la demande, et elle lit le résultat avec Astro.getActionResult().
Renvoyez l'URL depuis le handler, puis, dans le frontmatter de la page, vérifiez Astro.getActionResult() et renvoyez Astro.redirect() avec cette URL quand il n'y a pas d'erreur. Une URL absolue comme votre lien Cal.com fonctionne.
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.