Liste d'attente pour un nouveau SaaS7 min de lecture
Créer une liste d'attente avec Nuxt
Une liste d'attente Nuxt sur une landing page pré-rendue : une route Nitro accepte $fetch et les envois classiques, valide avec Zod et redirige sans JavaScript.
Pré-rendez la landing page avec routeRules et faites envoyer le formulaire vers une server route Nitro qui valide l'e-mail et le rôle avec Zod et les transmet à votre backend de formulaires avec une clé secrète issue de runtimeConfig. La route répond en JSON à $fetch et par une redirection 303 à un envoi de formulaire classique : l'inscription fonctionne donc avant l'hydratation de la page.
Le guide principal a un onglet Nuxt : une server route, runtimeConfig et un composant qui envoie avec $fetch. Ce formulaire a besoin de JavaScript, n'envoie que l'e-mail et sert la page à chaque requête. Cette version pré-rend la landing page, pose une question de tri, et continue de fonctionner quand le bundle n'est pas chargé.
Ce qui change avec Nuxt
Nuxt rend côté serveur par défaut, et Nitro sert le dossier server/ à côté de vos pages. Trois détails façonnent une liste d'attente :
| Détail | Effet sur la liste d'attente |
|---|---|
routeRules avec prerender: true | La landing page est construite une fois en HTML. La server route s'exécute toujours à chaque requête |
readBody analyse aussi les corps urlencoded | Un simple <form> peut envoyer vers la même route que $fetch, tout comme un formulaire sur le site de quelqu'un d'autre |
| Une page pré-rendue n'a pas de query string au build | ?ref= et ?problem= doivent être lus après le montage de la page, pas pendant le rendu |
La deuxième ligne a deux facettes. C'est ce qui fait fonctionner le chemin sans JavaScript, et c'est pourquoi la route vérifie l'en-tête Sec-Fetch-Site avant toute chose : un formulaire sur un autre site pourrait sinon envoyer des inscriptions avec votre clé.
- La landing page est du HTML statique. Une fois montée, le composant copie ?ref= ou ?utm_source= dans sa valeur de source.
- Le formulaire envoie vers la server route : en JSON via $fetch une fois Vue hydraté, ou en urlencoded comme envoi classique avant cela.
- La route refuse les envois cross-site, valide le corps avec Zod et transmet l'inscription à Ikiform avec la clé secrète.
- Un envoi JSON reçoit { joined : true }, ou une erreur dont le composant affiche le message sous le formulaire.
- Un envoi classique reçoit un 303 vers /thanks, ou vers / avec un code de problème que la page transforme en message une fois montée.
Configurer le projet
Créez le formulaire et la clé secrète comme dans le guide Nuxt de la documentation. Les fichiers de cette page, dans l'arborescence app/ de Nuxt 4 :
app/
pages/index.vue
pages/thanks.vue
components/WaitlistForm.vue
server/
api/waitlist.post.ts
nuxt.config.ts
.envexport default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
routeRules: {
"/": { prerender: true },
"/thanks": { prerender: true },
},
});Un ikiformSecretKey vide dans runtimeConfig est rempli à l'exécution depuis NUXT_IKIFORM_SECRET_KEY. Les clés hors de public n'existent que côté serveur. Ajoutez Zod avec npm install zod.
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Ce formulaire envoie email, role et ref. Si vous ajoutez une définition de champs dans l'onglet Fields du formulaire, listez les trois, avec les mêmes valeurs de rôle que le select :
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "role", "type": "select", "label": "What do you do?", "options": [{ "value": "founder", "label": "Founder" }, { "value": "engineer", "label": "Engineer" }, { "value": "designer", "label": "Designer" }, { "value": "other", "label": "Something else" }] },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}La construire
La server route
import { z } from "zod";
const Signup = z.object({
email: z.email(),
role: z.enum(["founder", "engineer", "designer", "other"]).optional().catch(undefined),
ref: z.string().regex(/^[a-z0-9_-]{1,40}$/).catch("direct"),
_gotcha: z.string().catch(""),
});
const MESSAGES = {
email: "Enter the email we should send your invite to.",
busy: "Lots of people are joining. Try again in a minute.",
failed: "Couldn't join the list. Try again.",
};
type Problem = keyof typeof MESSAGES;
export default defineEventHandler(async (event) => {
if (getRequestHeader(event, "sec-fetch-site") === "cross-site") {
throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
}
const plain = !getRequestHeader(event, "content-type")?.includes("application/json");
const answer = (problem: Problem | null) => {
if (plain) return sendRedirect(event, problem ? `/?problem=${problem}` : "/thanks", 303);
if (problem) throw createError({ status: problem === "busy" ? 429 : 400, message: MESSAGES[problem] });
return { joined: true };
};
const parsed = Signup.safeParse(await readBody(event));
if (!parsed.success) return answer("email");
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(parsed.data),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
if (!response) return answer("failed");
if (response.status === 429) return answer("busy");
const result = await response.json().catch(() => null);
if (result?.ok) return answer(null);
return answer(result?.error?.fields?.email ? "email" : "failed");
});- Le suffixe
.post.tslimite la route àPOST. Toute autre méthode reçoit405. Sec-Fetch-Sitevautsame-originpour vos propres pages etcross-sitepour un formulaire ailleurs. Les navigateurs le définissent et les scripts de page ne peuvent pas le modifier.- Le
.catch()de Zod transforme unroleou unrefinvalide en valeur sûre au lieu de faire échouer l'inscription. Seul un e-mail invalide mérite d'afficher une erreur. - L'en-tête
Origindu navigateur est transmis car Ikiform s'en sert pour compter les soumissions de test gratuites depuislocalhost, et lefetchde la route n'en envoie aucun. useRuntimeConfig(event)lit la clé à chaque requête : une valeur définie chez l'hébergeur après le build est donc bien prise en compte.- Une redirection porte un code de problème court, jamais le message : un lien forgé ne peut donc pas écrire de texte sur votre page.
Le composant formulaire
<script setup lang="ts">
const MESSAGES: Record<string, string> = {
email: "Enter the email we should send your invite to.",
busy: "Lots of people are joining. Try again in a minute.",
failed: "Couldn't join the list. Try again.",
};
const route = useRoute();
const email = ref("");
const role = ref("");
const source = ref("direct");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
onMounted(() => {
const tag = String(route.query.ref ?? route.query.utm_source ?? "").toLowerCase().slice(0, 40);
if (tag) source.value = tag;
const problem = String(route.query.problem ?? "");
if (MESSAGES[problem]) error.value = MESSAGES[problem];
});
async function join() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/waitlist", {
method: "POST",
body: { email: email.value, role: role.value || undefined, ref: source.value, _gotcha: trap.value },
});
status.value = "joined";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else method="post" action="/api/waitlist" @submit.prevent="join">
<label for="email">Email</label>
<input id="email" v-model="email" name="email" type="email" autocomplete="email" required />
<label for="role">What do you do?</label>
<select id="role" v-model="role" name="role">
<option value="">Choose one</option>
<option value="founder">Founder</option>
<option value="engineer">Engineer</option>
<option value="designer">Designer</option>
<option value="other">Something else</option>
</select>
<input type="hidden" name="ref" :value="source" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>- Le formulaire garde
methodetaction. Avant l'hydratation, le navigateur l'envoie en urlencoded ; après,@submit.preventle confie à$fetch, qui envoie du JSON. onMountedlit la query. La lire pendant le rendu donnerait une valeur vide au HTML du build et une autre au navigateur, soit un décalage d'hydratation.- Un rôle vide est envoyé comme
undefined, que$fetchomet du corps JSON. - L'erreur de
$fetchporte le message de la route dansdata.message: le composant affiche donc la formulation du serveur.
Utilisez <WaitlistForm /> dans app/pages/index.vue, et écrivez app/pages/thanks.vue avec la phrase de remerciement. Le honeypot est transmis tel quel : les bots qui le remplissent sont donc stockés comme spam. Les envois avec la clé secrète contournent la vérification humaine d'Ikiform : activer Spam check n'apporte donc rien ici.
Notes de déploiement
Construisez avec nuxt build. nuxt generate pré-rend chaque page en site statique sans serveur : /api/waitlist ne s'exécuterait donc jamais. Les routeRules ci-dessus vous donnent des pages statiques et une route active à partir d'un seul build.
| Hébergeur | Configuration | Le secret |
|---|---|---|
| Vercel | Aucune configuration. Vercel détecte Nitro à l'import | NUXT_IKIFORM_SECRET_KEY sous Environment Variables |
| Netlify | Aucune configuration. Nuxt détecte le build Netlify | NUXT_IKIFORM_SECRET_KEY sous Environment variables |
| Cloudflare Pages | Aucune configuration avec l'intégration Git. Pour un téléversement direct, construisez avec --preset=cloudflare_pages | NUXT_IKIFORM_SECRET_KEY comme variable chiffrée dans les réglages du projet |
Vérifiez que tout fonctionne
- Lancez
nuxt dev, ouvrezhttp://localhost:3000/?ref=test, choisissez un rôle et inscrivez-vous. La phrase de remerciement remplace le formulaire. - Ouvrez Submissions dans le tableau de bord. L'inscription a
refàtestet le rôle choisi. - Désactivez JavaScript et inscrivez-vous sans rôle. Vous arrivez sur
/thanks, et la soumission n'a pas derole. - Avec JavaScript désactivé, envoyez
a@b. Vous arrivez sur/?problem=email, et le message s'affiche une fois JavaScript réactivé et la page rechargée. - 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
nuxt 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 |
|---|---|
404 sur /api/waitlist en production | Le site a été construit avec nuxt generate, qui ne livre aucun serveur. Construisez avec nuxt build et gardez prerender dans routeRules. |
Un avertissement de décalage d'hydratation sur l'input masqué ref | La query est lue pendant le rendu. Lisez-la dans onMounted, comme ci-dessus ; le HTML pré-rendu n'a jamais de query string. |
Chaque envoi répond failed | La clé est vide. La variable doit s'appeler NUXT_IKIFORM_SECRET_KEY pour remplir runtimeConfig.ikiformSecretKey. |
422 avec unknown_field pour role ou ref | La définition des champs ne les liste pas. Ajoutez les deux, 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. Donnez au formulaire une méthode et une action qui pointent vers une server route. Le readBody de Nitro analyse l'envoi urlencoded, et la route peut y répondre par une redirection, tandis que les envois $fetch depuis la page hydratée reçoivent du JSON.
Oui. Pré-rendez la page avec routeRules et gardez la server route du formulaire active, puis construisez avec nuxt build. Lisez les valeurs de query comme ref après le montage de la page.
Dans une clé runtimeConfig privée, remplie depuis une variable d'environnement NUXT_ chez l'hébergeur. Les clés privées ne sont lisibles que côté serveur, via useRuntimeConfig.
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.