Liste d'attente pour un nouveau SaaS

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.

Preet SutharFondateur, Ikiform

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étailEffet sur la liste d'attente
routeRules avec prerender: trueLa landing page est construite une fois en HTML. La server route s'exécute toujours à chaque requête
readBody analyse aussi les corps urlencodedUn 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é.

Une route Nitro répond à $fetch en JSON et à un envoi classique par une redirection/pré-rendueonMountedlit?ref=server/api/waitlist.post.tsvérif.same-site,ZodIkiformclésecrète,stocke{joined:true}$fetch,mêmepage303/thanksou/?problem=emailpagepré-rendueaffichelerésultatPOSTJSONcontent-type jsonurlencodedUne route Nitro répond à $fetch en JSON et à un envoi classique par une redirection/ pré-rendueonMounted lit ?ref=server/api/waitlist.post.tsvérif. same-site, ZodIkiformclé secrète, stocke{ joined: true }$fetch, même page303/thanksou/?problem=emailpagepré-rendueaffiche lerésultatPOSTJSONcontent-type jsonurlencoded
  1. La landing page est du HTML statique. Une fois montée, le composant copie ?ref= ou ?utm_source= dans sa valeur de source.
  2. Le formulaire envoie vers la server route : en JSON via $fetch une fois Vue hydraté, ou en urlencoded comme envoi classique avant cela.
  3. La route refuse les envois cross-site, valide le corps avec Zod et transmet l'inscription à Ikiform avec la clé secrète.
  4. Un envoi JSON reçoit { joined : true }, ou une erreur dont le composant affiche le message sous le formulaire.
  5. 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.
Le type de contenu de l'envoi décide de la réponse. Les deux pages où arrive le navigateur sont pré-rendues.

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 :

Projet
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
nuxt.config.ts
export 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.

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

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 :

Champs
{
  "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

server/api/waitlist.post.ts
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.ts limite la route à POST. Toute autre méthode reçoit 405.
  • Sec-Fetch-Site vaut same-origin pour vos propres pages et cross-site pour un formulaire ailleurs. Les navigateurs le définissent et les scripts de page ne peuvent pas le modifier.
  • Le .catch() de Zod transforme un role ou un ref invalide 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 Origin du navigateur est transmis car Ikiform s'en sert pour compter les soumissions de test gratuites depuis localhost, et le fetch de 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

app/components/WaitlistForm.vue
<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 method et action. Avant l'hydratation, le navigateur l'envoie en urlencoded ; après, @submit.prevent le confie à $fetch, qui envoie du JSON.
  • onMounted lit 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 $fetch omet du corps JSON.
  • L'erreur de $fetch porte le message de la route dans data.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ébergeurConfigurationLe secret
VercelAucune configuration. Vercel détecte Nitro à l'importNUXT_IKIFORM_SECRET_KEY sous Environment Variables
NetlifyAucune configuration. Nuxt détecte le build NetlifyNUXT_IKIFORM_SECRET_KEY sous Environment variables
Cloudflare PagesAucune configuration avec l'intégration Git. Pour un téléversement direct, construisez avec --preset=cloudflare_pagesNUXT_IKIFORM_SECRET_KEY comme variable chiffrée dans les réglages du projet

Vérifiez que tout fonctionne

  1. Lancez nuxt dev, ouvrez http://localhost:3000/?ref=test, choisissez un rôle et inscrivez-vous. La phrase de remerciement remplace le formulaire.
  2. Ouvrez Submissions dans le tableau de bord. L'inscription a ref à test et le rôle choisi.
  3. Désactivez JavaScript et inscrivez-vous sans rôle. Vous arrivez sur /thanks, et la soumission n'a pas de role.
  4. 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.
  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 nuxt 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.

Pièges

ProblèmeQue faire
404 sur /api/waitlist en productionLe 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é refLa 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 failedLa clé est vide. La variable doit s'appeler NUXT_IKIFORM_SECRET_KEY pour remplir runtimeConfig.ikiformSecretKey.
422 avec unknown_field pour role ou refLa définition des champs ne les liste pas. Ajoutez les deux, comme dans la définition ci-dessus.
402 plan_requiredL'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 étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.