# Créer une liste d'attente avec Astro

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

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

Gardez la landing page pré-rendue et placez le travail serveur dans une Astro Action qui valide l'e-mail et le transmet à votre backend de formulaires avec une clé secrète. Un court script appelle l'action pour que le visiteur reste sur la page, et l'`action` classique du formulaire pointe vers une page rendue à la demande qui exécute la même action quand JavaScript n'est pas chargé.

Le [guide principal](https://ikiform.com/fr/guides/waitlist.md#form) a un court onglet Astro : une action, un formulaire et une page rendue à la demande. Cela fonctionne, mais renonce à ce pour quoi la plupart des gens choisissent Astro. Une landing page est la page que vous voulez le plus servir en HTML statique depuis un CDN, et une form action Astro l'oblige à être rendue à chaque requête. Cette page garde la landing page statique et ne déplace que l'inscription vers le serveur.

## Ce qui change avec Astro

Astro pré-rend chaque page par défaut. Cela a deux effets sur une liste d'attente que les autres frameworks ne partagent pas :

|   | Landing page pré-rendue | Page rendue à la demande |
| --- | --- | --- |
| Servie depuis | Un CDN, sous forme de fichier | Le serveur ou la fonction de votre adaptateur |
| `<form action={actions.x}>` | Interdit. Astro exige une page rendue à la demande | Fonctionne sans JavaScript |
| `?ref=` dans l'URL | Invisible pour le serveur. Construite une fois, sans paramètres | Lisible depuis `Astro.url` |
| `Astro.clientAddress` | Indisponible | Disponible |

La landing page ne peut donc ni utiliser le modèle de form action, ni lire `?ref=` au build. La solution est de répartir les rôles : la page statique porte le formulaire et un script, l'action fait le travail serveur, et une petite page, `/join`, est rendue à la demande pour traiter un envoi qui arrive sans JavaScript.

Schéma : Deux chemins vers une même Astro Action depuis une landing page statique

1. La landing page est du HTML simple servi par le CDN. Quand son script se charge, il copie ?ref= ou ?utm_source= dans l'input masqué ref.
2. Avec JavaScript, le script bloque l'envoi normal et appelle actions.joinWaitlist avec les données du formulaire. L'action s'exécute côté serveur et transmet l'inscription à Ikiform.
3. Le script remplace le formulaire par une phrase de remerciement, ou affiche l'erreur sous l'input.
4. Sans JavaScript, le navigateur envoie le formulaire vers /join, rendue à la demande.
5. /join appelle la même action avec Astro.callAction. En cas de succès, elle redirige vers /thanks avec un 303, pour qu'un rafraîchissement ne renvoie pas le formulaire. En cas d'erreur, elle affiche le formulaire avec le message.

Une seule action, deux entrées. Seules /join et l'action elle-même ont besoin d'un serveur.

## Configurer le projet

La configuration côté produit, comme la création du formulaire et de la clé secrète, est dans le [guide Astro de la documentation](https://ikiform.com/fr/docs/guides/astro.md). Les fichiers de cette page :

`Projet`:

```text
src/
  actions/index.ts
  components/WaitlistForm.astro
  pages/index.astro
  pages/join.astro
  pages/thanks.astro
astro.config.mjs
.env
```

### Un adaptateur et un secret typé

Les actions s'exécutent côté serveur au moment de la requête : le projet a donc besoin d'un adaptateur même si la plupart des pages restent statiques. `astro add` en installe un et modifie la configuration. Remplacez `node` par `vercel`, `netlify` ou `cloudflare` selon votre hébergeur.

`Terminal`:

```bash
npx astro add node
```

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`envField` avec `access: "secret"` rend `IKIFORM_SECRET_KEY` importable uniquement depuis `astro:env/server`. Importez-la dans un script client et le build échoue, ce qui est le but. Créez la clé sous **API keys** avec le scope `submissions:write`.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

### La définition des champs

L'action envoie `email` et `ref`. Si vous ajoutez une définition de champs dans l'onglet **Fields** du formulaire, elle doit lister les deux, sinon Ikiform refuse l'envoi avec `422` et `unknown_field` :

`Champs`:

```json
{
  "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 } }
  ]
}
```

## La construire

### 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 = /^[a-z0-9_-]{1,40}$/;

export const server = {
  joinWaitlist: defineAction({
    accept: "form",
    input: z.object({
      email: z.email("Enter the email we should send your invite to."),
      ref: z
        .string()
        .nullish()
        .transform((value) => (value && SOURCE.test(value) ? value : "direct")),
      _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 ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          email: input.email,
          ref: input.ref,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      if (!response) {
        throw new ActionError({ code: "GATEWAY_TIMEOUT", message: "We couldn't reach the list. Try again." });
      }
      if (response.status === 429) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Lots of people are joining. Try again in a minute." });
      }
      const result = await response.json().catch(() => null);
      if (result?.ok) return { joined: true };
      throw new ActionError({
        code: "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"` analyse le `FormData`. Les inputs vides arrivent en `null` : `ref` et le honeypot sont donc `nullish()`, et un `ref` absent devient `direct`.
- `ref` doit être composé de lettres minuscules, de chiffres, de `-` ou de `_`, jusqu'à 40 caractères. Tout le reste est stocké comme `direct`, pour qu'un lien forgé ne puisse pas remplir votre export de déchets. La limite correspond au `max_length` de la définition.
- `z.email()` est le schéma e-mail de premier niveau de Zod 4, exporté depuis `astro/zod`. Une adresse invalide échoue ici et n'atteint jamais Ikiform.
- Le navigateur envoie un en-tête `Origin` avec l'appel du script comme avec l'envoi classique. Ikiform s'en sert pour compter les soumissions de test gratuites et le `fetch` de l'action n'en envoie aucun : l'action le transmet donc.
- Le honeypot passe tel quel, Ikiform marque donc toujours comme spam les bots qui le remplissent. Les envois avec la clé secrète contournent la vérification humaine d'Ikiform : activer **Spam check** n'apporte donc rien ici ; c'est l'action qui fait office de garde-fou.
- Chaque échec lève une `ActionError` avec un code correspondant au problème. Le script et `/join` lisent tous deux `error.message`.

### Le composant formulaire

`src/components/WaitlistForm.astro`:

```astro
---
interface Props {
  error?: string;
}

const { error = "" } = Astro.props;
---

<form method="POST" action="/join" data-waitlist>
  <label>
    Email
    <input name="email" type="email" autocomplete="email" required placeholder="you@company.com" />
  </label>
  <input type="hidden" name="ref" value="direct" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p role="alert">{error}</p>
  <button>Join the waitlist</button>
</form>

<script>
  import { actions, isInputError } from "astro:actions";

  const query = new URLSearchParams(location.search);
  const source = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase().slice(0, 40);

  for (const form of document.querySelectorAll<HTMLFormElement>("form[data-waitlist]")) {
    const ref = form.elements.namedItem("ref") as HTMLInputElement;
    const alert = form.querySelector<HTMLElement>("[role=alert]")!;
    const button = form.querySelector("button")!;
    if (source) ref.value = source;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      button.textContent = "Joining";
      alert.textContent = "";

      const { error } = await actions.joinWaitlist(new FormData(form));
      if (!error) {
        const done = document.createElement("p");
        done.setAttribute("role", "status");
        done.textContent = "You're on the list. We'll email you on launch day.";
        form.replaceWith(done);
        return;
      }
      alert.textContent = isInputError(error) ? (error.fields.email?.[0] ?? "Check the address.") : error.message;
      button.disabled = false;
      button.textContent = "Join the waitlist";
    });
  }
</script>
```

- L'`action` du formulaire est `/join`, une URL normale. Sans JavaScript, le navigateur envoie là : le formulaire fonctionne donc avant le chargement du script ou si celui-ci échoue.
- Le script lit `?ref=` dans le navigateur, car une page pré-rendue a été construite sans paramètres d'URL. Un visiteur venu de `/?ref=newsletter` est tagué `newsletter`.
- Astro inclut le `<script>` d'un composant une seule fois par page : la boucle branche donc chaque formulaire de liste d'attente de la page. Mettez-en un dans le hero et un dans le pied de page, et les deux fonctionnent.
- Le label englobe l'input au lieu de pointer vers un `id`, pour que deux copies sur une même page n'entrent pas en conflit.

### La landing page et /join

`src/pages/index.astro`:

```astro
---
import WaitlistForm from "../components/WaitlistForm.astro";
---

<main>
  <h1>Acme is almost here</h1>
  <p>Leave your email and we'll send you an invite the day it opens.</p>
  <WaitlistForm />
</main>
```

Pas d'export `prerender`, elle reste donc statique. `/join` s'en exclut et gère l'envoi classique :

`src/pages/join.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import WaitlistForm from "../components/WaitlistForm.astro";

export const prerender = false;

let error = "";
if (Astro.request.method === "POST") {
  const result = await Astro.callAction(actions.joinWaitlist, await Astro.request.formData());
  if (!result.error) return Astro.redirect("/thanks", 303);
  error = isInputError(result.error) ? (result.error.fields.email?.[0] ?? "Check the address.") : result.error.message;
  Astro.response.status = 400;
}
---

<main>
  <h1>Join the Acme waitlist</h1>
  <WaitlistForm error={error} />
</main>
```

`Astro.callAction` exécute l'action avec la même validation : il n'y a donc qu'un seul endroit à modifier. Le `303` envoie le navigateur vers `/thanks` en GET, une page statique que vous écrivez avec la même phrase de remerciement.

## Notes de déploiement

| Hébergeur | Adaptateur | Le secret |
| --- | --- | --- |
| Vercel | `npx astro add vercel` | Ajoutez `IKIFORM_SECRET_KEY` sous **Environment Variables** du projet |
| Netlify | `npx astro add netlify` | Ajoutez-la sous **Site configuration › Environment variables** |
| Cloudflare | `npx astro add cloudflare` | Ajoutez-la comme secret dans les réglages du Worker, ou avec `wrangler secret put IKIFORM_SECRET_KEY` |

Sur chaque hébergeur, la landing page et `/thanks` partent en fichiers statiques, et l'action plus `/join` deviennent une seule fonction serveur. Une journée calme ne coûte presque rien, et un pic de lancement touche surtout le CDN.

## Vérifiez que tout fonctionne

1. Lancez `astro dev` et ouvrez `http://localhost:4321/?ref=test`. Inscrivez-vous. Le formulaire devient la phrase de remerciement sans rechargement.
2. Ouvrez **Submissions** dans le tableau de bord. L'inscription y est, avec `ref` à `test`.
3. Désactivez JavaScript dans le navigateur, rechargez et inscrivez-vous à nouveau. Vous arrivez sur `/thanks`, et la nouvelle inscription a `ref` à `direct`.
4. Envoyez `not-an-email` avec JavaScript désactivé. `/join` réaffiche le formulaire avec l'erreur sur l'e-mail.
5. 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.
6. Les envois depuis `astro dev` portent une origine `localhost` : ils comptent donc comme des soumissions de test. 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).

## Pièges

| Problème | Que faire |
| --- | --- |
| `403` de votre propre site sur l'envoi classique | Le `security.checkOrigin` d'Astro, activé par défaut, refuse les envois de formulaire vers des pages à la demande depuis une autre origine. Envoyez depuis l'hôte où tourne le site, comme `localhost:4321` en dev, pas via un autre domaine de préproduction. |
| Une erreur de build sur les form actions dans une page pré-rendue | Un formulaire utilise `action={actions.joinWaitlist}` sur une page statique. Gardez l'action à `/join`, comme ci-dessus, ou ajoutez `export const prerender = false` à cette page. |
| `ref` vaut toujours `direct` | Le lien utilise une valeur avec des majuscules ou des espaces, ou le script ne s'est pas chargé. Le script met la valeur en minuscules ; tout ce qui reste hors de `a-z`, `0-9`, `-` et `_` est écarté par l'action. |
| `422` avec `unknown_field` | La définition des champs du formulaire ne liste pas `ref`. Ajoutez-le comme dans la définition ci-dessus. |
| `402 plan_required` | L'espace de travail n'a pas d'offre et l'envoi venait d'un site en ligne. Il est mis en attente, pas perdu. Choisissez une offre ou achetez un pack sous **Settings › Plan and usage**, et les soumissions en attente arrivent. |

## Questions

### Une page de liste d'attente Astro peut-elle rester statique ?

Oui. Laissez la landing page pré-rendue, appelez l'action depuis un script client, et faites pointer l'action classique du formulaire vers une page rendue à la demande. Seules l'action et cette page ont besoin d'un serveur.

### Les Astro Actions ont-elles besoin d'un adaptateur ?

Oui. Les actions s'exécutent côté serveur quand elles sont appelées : le projet a donc besoin d'un adaptateur comme Node, Vercel, Netlify ou Cloudflare, même si toutes les pages sont pré-rendues.

### Pourquoi ne pas envoyer le formulaire Astro directement à Ikiform ?

Vous pouvez, et cela garde la limite de débit par visiteur et la vérification humaine d'Ikiform. L'action se justifie quand vous voulez d'abord valider et nettoyer le tag de source, ou garder l'endpoint hors du HTML de votre page.

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

- [Liste d'attente pour un nouveau SaaS](https://ikiform.com/fr/guides/waitlist.md): 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.
- [Liste d'attente Next.js](https://ikiform.com/fr/guides/waitlist/nextjs.md): Une 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 en HTML simple](https://ikiform.com/fr/guides/waitlist/html.md): Une 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 de lancement Product Hunt](https://ikiform.com/fr/guides/waitlist/product-hunt.md): Collectez 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élection](https://ikiform.com/fr/guides/waitlist/beta-signup.md): Un 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 mobile](https://ikiform.com/fr/guides/waitlist/mobile-app.md): Une 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.
