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

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

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

Schéma : Une route Nitro répond à $fetch en JSON et à un envoi classique par une redirection

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](https://ikiform.com/fr/docs/guides/nuxt.md). Les fichiers de cette page, dans l'arborescence `app/` de Nuxt 4 :

`Projet`:

```text
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

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

```bash
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](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.

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

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

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

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

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

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

### Un formulaire Nuxt peut-il fonctionner sans JavaScript ?

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.

### Puis-je pré-rendre une page Nuxt qui contient un formulaire de liste d'attente ?

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.

### Où garder la clé secrète dans Nuxt ?

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.

### 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 SvelteKit](https://ikiform.com/fr/guides/waitlist/sveltekit.md): Une liste d'attente SvelteKit 3 : un hook garde le premier ?ref= dans un cookie, une action nommée transmet l'inscription, les visiteurs qui reviennent voient qu'ils sont inscrits.
- [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 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.
- [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.
