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

Publié le 2026-10-25 · https://ikiform.com/fr/guides/lead-capture/astro

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](https://ikiform.com/fr/guides/lead-capture.md#form) 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.

Schéma : Où Astro exécute chaque partie d'un formulaire de leads

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](https://ikiform.com/fr/docs/guides/astro.md) explique comment créer le formulaire et la clé.

`fichiers`:

```text
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`:

```js
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 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 |

> 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](https://ikiform.com/fr/docs/rate-limits.md) 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`:

```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`:

```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`:

```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](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/fr/pricing).

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è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

### Puis-je suivre les paramètres UTM sur un site Astro statique ?

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.

### Une action de formulaire Astro fonctionne-t-elle sans JavaScript ?

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().

### Comment rediriger après le succès d'une Astro Action ?

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.

### Ikiform est-il gratuit ?

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.

## Autres guides

- [Formulaire de capture de leads](https://ikiform.com/fr/guides/lead-capture.md): Un formulaire de capture de leads sur votre propre site, qui vous écrit avec le lead en adresse de réponse, alerte le chat de l'équipe et alimente votre CRM, pour répondre tant que le lead est encore là.
- [Formulaire de leads Next.js](https://ikiform.com/fr/guides/lead-capture/nextjs.md): Un formulaire de leads Next.js App Router qui valide avec Zod dans une server action, garde les UTM du premier contact dans un cookie et envoie les leads qualifiés vers votre agenda.
- [Formulaire de leads SvelteKit](https://ikiform.com/fr/guides/lead-capture/sveltekit.md): Un formulaire de leads SvelteKit : le hook handle enregistre les UTM du premier contact, une form action valide avec Valibot et redirect() envoie les leads qualifiés vers un agenda.
- [Formulaire de demande de démo](https://ikiform.com/fr/guides/lead-capture/demo-request.md): 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.
- [Formulaire de demande de devis](https://ikiform.com/fr/guides/lead-capture/quote-request.md): Un formulaire de demande de devis qui reçoit les briefs sous forme de liens partagés plutôt qu'en pièces jointes, fixe une échéance à chaque demande et garde les leads riches en liens hors du spam.
- [Formulaire de demande de consultation](https://ikiform.com/fr/guides/lead-capture/consultation-request.md): Un formulaire de demande de consultation pour comptables, avocats, coachs et cabinets, qui demande comment et quand appeler, et garde les détails sensibles pour l'appel.
