Liste d'attente pour un nouveau SaaS8 min de lecture
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.
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 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 :
| Item | 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.
- 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.
- 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.
- Le script remplace le formulaire par une phrase de remerciement, ou affiche l'erreur sous l'input.
- Sans JavaScript, le navigateur envoie le formulaire vers /join, rendue à la demande.
- /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.
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. Les fichiers de cette page :
src/
actions/index.ts
components/WaitlistForm.astro
pages/index.astro
pages/join.astro
pages/thanks.astro
astro.config.mjs
.envUn 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.
npx astro add nodeimport { 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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 :
{
"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
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 leFormData. Les inputs vides arrivent ennull:refet le honeypot sont doncnullish(), et unrefabsent devientdirect.refdoit être composé de lettres minuscules, de chiffres, de-ou de_, jusqu'à 40 caractères. Tout le reste est stocké commedirect, pour qu'un lien forgé ne puisse pas remplir votre export de déchets. La limite correspond aumax_lengthde la définition.z.email()est le schéma e-mail de premier niveau de Zod 4, exporté depuisastro/zod. Une adresse invalide échoue ici et n'atteint jamais Ikiform.- Le navigateur envoie un en-tête
Originavec l'appel du script comme avec l'envoi classique. Ikiform s'en sert pour compter les soumissions de test gratuites et lefetchde 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
ActionErroravec un code correspondant au problème. Le script et/joinlisent tous deuxerror.message.
Le composant formulaire
---
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'
actiondu 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=newsletterest 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
---
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 :
---
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
- Lancez
astro devet ouvrezhttp://localhost:4321/?ref=test. Inscrivez-vous. Le formulaire devient la phrase de remerciement sans rechargement. - Ouvrez Submissions dans le tableau de bord. L'inscription y est, avec
refàtest. - Désactivez JavaScript dans le navigateur, rechargez et inscrivez-vous à nouveau. Vous arrivez sur
/thanks, et la nouvelle inscription arefàdirect. - Envoyez
not-an-emailavec JavaScript désactivé./joinréaffiche le formulaire avec l'erreur sur l'e-mail. - 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. - Les envois depuis
astro devportent une originelocalhost: 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 depuislocalhost. 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.
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
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.
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.
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.
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.