Formulaire de capture de leads

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.

Preet SutharFondateur, Ikiform

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.

Où Astro exécute chaque partie d'un formulaire de leadsattributionenvoimiddlewareune seule fois,sans visiteurscriptFirstTouchlit ?utm_ et lereferrercookielead_sourceenvoyé avec lePOSTPOST duformulaireaction={actions.submitLead}Astro ActionZod, lit lecookieAstro.redirectCal.com ou/thanksmême pagemessage souschaque champposedonnées du formulaireresult.data.nextisInputErrorau builddans le navigateurà la demandeOù Astro exécute chaque partie d'un formulaire de leadsattributionenvoimiddlewareune seule fois, sansvisiteurscriptFirstTouchlit ?utm_ et lereferrercookielead_sourceenvoyé avec lePOSTPOST duformulaireaction={actions.submitLead}Astro ActionZod, lit le cookieAstro.redirectCal.com ou /thanksmêmepagemessagesous chaquechampposedonnées du formulaireresult.data.nextisInputError
  1. 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.
  2. Dans le navigateur, le script FirstTouch lit les tags UTM et le referrer à la première page vue.
  3. Il les stocke une fois dans un cookie lead_source que portent toutes les requêtes suivantes.
  4. Le formulaire de contact envoie à l'action avec un simple formulaire HTML, sans JavaScript.
  5. L'action valide avec Zod, lit le cookie et transmet le lead à Ikiform.
  6. 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.
Seule la page de contact a besoin d'un serveur. Tout ce que le visiteur lit avant reste statique.

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é.

fichiers
src/
  actions/index.ts
  components/FirstTouch.astro
  layouts/Base.astro
  pages/
    pricing.astro
    contact-sales.astro
    contact-sales/thanks.astro
astro.config.mjs
.env
astro.config.mjs
import { 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 champType dans IkiformRenseigné par
name, email, companyTexte, e-mail, texteLe visiteur
company_sizeSelect : 1_10, 11_50, 51_200, 201_1000, 1000_plusLe visiteur
budgetSelect : under_10k, 10k_50k, 50k_plus, unsureLe visiteur
timelineSelect : this_quarter, next_quarter, later, researchingLe visiteur
messageTextarea, facultatifLe visiteur
utm_source, utm_medium, utm_campaign, referrer, landing_pageTexte, facultatifVotre serveur, depuis la première visite
fitSelect : qualified, nurtureVotre serveur, après validation
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Le premier contact sur un site statique

src/components/FirstTouch.astro
<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.ts avec context.cookies.set(). Vérifiez d'abord context.isPrerendered et passez votre chemin.

L'action du formulaire

src/actions/index.ts
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 en null avant que Zod ne le voie. Un select sans choix échoue donc sur z.enum avec votre propre message, et un texte facultatif a besoin de nullish().
  • L'en-tête Origin du 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 depuis localhost. Dans astro dev, c'est http://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 429 d'Ikiform devient TOO_MANY_REQUESTS, pour que la page distingue un formulaire saturé d'une saisie invalide.

La page de contact

src/pages/contact-sales.astro
---
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.

  1. Lancez astro dev, ouvrez /pricing?utm_source=test&utm_campaign=launch et vérifiez le cookie lead_source dans les outils de développement.
  2. Allez sur /contact-sales, laissez Budget vide et envoyez avec JavaScript désactivé. La page se recharge avec l'erreur de budget.
  3. 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.
  4. Envoyez avec Researching for now. Vous arrivez sur /contact-sales/thanks.
  5. Ouvrez Submissions dans le tableau de bord et vérifiez utm_source, referrer, landing_page et fit sur les deux leads.
  6. 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èmeQue faire
Le formulaire envoie et la page n'affiche rien de nouveauLa 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 erreurAstro 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 tagsLe 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 buildastro:env valide le schéma. Définissez la variable dans .env en local et dans les réglages de votre hébergeur.
422 validation_failedL'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

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.