# Comment créer un formulaire d'inscription à une bêta qui sélectionne les candidats

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

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

Posez quelques questions de sélection à côté de l'e-mail, et laissez le backend de formulaires les faire respecter pour que chaque candidature arrive complète et comparable. Choisissez ensuite chaque cohorte dans la liste avec un court script de notation, invitez un petit groupe, et laissez les autres attendre le prochain tour.

Une liste d'attente écrit à tout le monde le jour du lancement. Une bêta fait l'inverse : vous faites entrer vingt personnes, apprenez d'elles, puis en faites entrer vingt autres. Cela change le formulaire. Il vous faut des réponses que vous pouvez trier, pas seulement une adresse.

## Ce qui change pour une bêta

- **C'est une sélection.** Vous invitez quelques personnes à la fois : il vous faut donc un moyen de classer les candidats entre eux. Les choix fermés, comme le rôle et la taille de l'équipe, se trient proprement. Le texte libre, non.
- **Une candidature incomplète coûte une place.** Si quelqu'un saute la question qui vous dit s'il correspond, vous ne pouvez ni le choisir ni l'écarter. Rendez les questions de sélection obligatoires, et faites refuser par le serveur les envois qui les sautent.
- **Les invitations partent par vagues.** Votre liste doit se souvenir de qui vous avez déjà invité, pour que la cohorte suivante les ignore. Ikiform n'a pas de tags : cet état vit donc dans votre script ou votre tableur.
- **Les personnes non retenues comptent toujours.** C'est votre prochaine cohorte. Dites-leur dans la phrase de remerciement que les groupes sont ouverts au fil du temps, pour qu'une boîte silencieuse ne soit pas prise pour un refus.

## Questions de sélection

Six questions, c'est le maximum. Chacune doit changer qui vous invitez ; si vous ne savez pas dire comment une réponse ferait monter ou descendre quelqu'un, supprimez-la. Essayez le formulaire ci-dessous. Il exécute la même définition de champs qu'Ikiform applique côté serveur : vous voyez donc le `422` que reçoit une candidature incomplète.

Formulaire : Beta application

- **E-mail professionnel** (`email`, email, obligatoire)
- **Votre rôle** (`role`, select, obligatoire)
- **Taille de l'équipe** (`team_size`, radio, obligatoire)
- **À quoi l'utiliseriez-vous en premier ?** (`use_case`, textarea, obligatoire)
- **Quel outil utilisez-vous aujourd'hui ?** (`current_tool`, text)
- **Je suis partant pour un appel de 20 minutes** (`interview`, checkbox)

Collez cette définition dans l'onglet **Fields** du formulaire. La réponse `use_case` exige au moins 40 caractères, ce qui écarte les candidatures en un mot.

| Champ | Ce qu'il permet de décider | Pourquoi ce type |
| --- | --- | --- |
| `email` | Qui inviter | Obligatoire. Un e-mail professionnel donne une idée de l'entreprise sans la demander |
| `role` | S'ils ressentent le problème que vous résolvez | Un `select` avec vos propres catégories, pour pouvoir les pondérer |
| `team_size` | Si votre première version convient à leur configuration | Un `radio` : une seule réponse, toujours comparable |
| `use_case` | S'ils utiliseront la bêta ce mois-ci | Un `textarea` avec une longueur minimale. Le signal le plus sincère du formulaire |
| `current_tool` | Qui vous remplacez, et à quoi ils vous compareront | Texte libre facultatif |
| `interview` | Avec qui vous pouvez échanger | Une `checkbox`. Absente signifie `false`, elle ne bloque donc jamais une candidature |

> Avec une définition de champs en place, Ikiform refuse tout champ qu'il ne connaît pas avec `422` et une erreur `unknown_field`. Si vous ajoutez un input masqué comme `ref`, ajoutez-le aussi à la définition.

## Le formulaire

Chaque version ci-dessous affiche le message du serveur sous le champ concerné, car un formulaire de sélection échoue pour plus de raisons qu'un e-mail invalide. Chacune conserve ce que la personne a saisi quand un envoi est refusé, pour que personne ne retape un paragraphe.

**HTML**

Les attributs natifs `required` et `minlength` détectent la plupart des oublis avant l'envoi. Le formulaire classique fonctionne sans script et arrive sur votre page de remerciement ; le script garde les gens sur la page et liste chaque erreur de champ.

`beta.html`:

```html
<form id="beta-form" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required>

  <label for="role">Your role</label>
  <select id="role" name="role" required>
    <option value="">Choose one</option>
    <option value="founder">Founder</option>
    <option value="engineer">Engineer</option>
    <option value="product">Product manager</option>
    <option value="designer">Designer</option>
    <option value="other">Something else</option>
  </select>

  <fieldset>
    <legend>Team size</legend>
    <label><input type="radio" name="team_size" value="1" required> Only me</label>
    <label><input type="radio" name="team_size" value="2-10"> 2 to 10</label>
    <label><input type="radio" name="team_size" value="11-50"> 11 to 50</label>
    <label><input type="radio" name="team_size" value="51+"> More than 50</label>
  </fieldset>

  <label for="use_case">What would you use it for first?</label>
  <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>

  <label for="current_tool">What do you use today?</label>
  <input id="current_tool" name="current_tool" maxlength="120">

  <label><input type="checkbox" name="interview" value="true"> I'm open to a 20-minute call</label>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden>
  <ul id="beta-errors" role="alert"></ul>
  <button>Apply for the beta</button>
</form>
```

`Avant </body>`:

```html
<script>
  const form = document.getElementById("beta-form");
  const errors = document.getElementById("beta-errors");
  const labels = { email: "Work email", role: "Role", team_size: "Team size", use_case: "First use", current_tool: "Current tool" };

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button");
    button.disabled = true;
    errors.replaceChildren();
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const result = await response.json();
      if (result.ok) {
        form.outerHTML = "<p role=\"status\">Thanks for applying. We invite testers in small groups and will email you either way.</p>";
        return;
      }
      const fields = Object.entries(result.error.fields ?? {});
      const lines = fields.length ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`) : [result.error.message];
      errors.replaceChildren(...lines.map((line) => Object.assign(document.createElement("li"), { textContent: line })));
    } catch {
      errors.textContent = "Couldn't reach the server. Your answers are still here; try again.";
    }
    button.disabled = false;
  });
</script>
```

**Next.js**

React réinitialise un formulaire après l'exécution d'une server action, ce qui effacerait une longue réponse en cas de `422`. L'action renvoie les réponses dans `values`, et chaque input les relit via `defaultValue`.

`app/beta/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

export interface ApplyState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
  values?: Record<string, string>;
}

export async function apply(_previous: ApplyState, formData: FormData): Promise<ApplyState> {
  const answers = Object.fromEntries(
    [...formData].filter((entry): entry is [string, string] => typeof entry[1] === "string" && !entry[0].startsWith("$ACTION")),
  );
  const origin = (await 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 ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(answers),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way." };
  return { ok: false, message: result?.error?.message ?? "Couldn't send your application. Try again.", fields: result?.error?.fields, values: answers };
}
```

`app/beta/beta-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type ApplyState, apply } from "./actions";

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

export function BetaForm() {
  const [state, action, pending] = useActionState(apply, { ok: false, message: "" } as ApplyState);
  const errors = state.fields ?? {};
  const values = state.values ?? {};

  if (state.ok) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      <FieldError messages={errors.email} />
      <label htmlFor="role">Your role</label>
      <select id="role" name="role" required defaultValue={values.role}>
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="product">Product manager</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required defaultChecked={values.team_size === size} /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required defaultValue={values.use_case} />
      <FieldError messages={errors.use_case} />
      <label htmlFor="current_tool">What do you use today?</label>
      <input id="current_tool" name="current_tool" maxLength={120} defaultValue={values.current_tool} />
      <label>
        <input type="checkbox" name="interview" value="true" defaultChecked={values.interview === "true"} /> I'm open to a 20-minute call
      </label>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
    </form>
  );
}
```

**Astro**

Sans schéma `input`, une Astro Action reçoit le `FormData` brut et c'est Ikiform qui valide. Le handler renvoie les erreurs de champ comme des données au lieu de lever une exception, pour que la page puisse placer chaque message.

`src/actions/index.ts`:

```ts
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";

export const server = {
  apply: defineAction({
    accept: "form",
    handler: async (formData: FormData, context) => {
      const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
      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 ${getSecret("IKIFORM_SECRET_KEY")}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(answers),
      });
      const result = await response.json();
      if (result.ok) return { ok: true, fields: {} as Record<string, string[]>, message: "" };
      return { ok: false, fields: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) };
    },
  }),
};
```

`src/pages/beta.astro`:

```astro
---
import { actions } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.apply)?.data;
const errors = result?.fields ?? {};
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
---

{result?.ok ? (
  <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
) : (
  <form method="POST" action={actions.apply}>
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {errors.email && <p role="alert">{errors.email[0]}</p>}
    <label for="role">Your role</label>
    <select id="role" name="role" required>
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="product">Product manager</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <fieldset>
      <legend>Team size</legend>
      {sizes.map(([size, label]) => <label><input type="radio" name="team_size" value={size} required /> {label}</label>)}
    </fieldset>
    <label for="use_case">What would you use it for first?</label>
    <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>
    {errors.use_case && <p role="alert">{errors.use_case[0]}</p>}
    <label for="current_tool">What do you use today?</label>
    <input id="current_tool" name="current_tool" maxlength="120" />
    <label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {result && !result.ok && !Object.keys(errors).length && <p role="alert">{result.message}</p>}
    <button>Apply for the beta</button>
  </form>
)}
```

**TanStack Start**

La server function transmet chaque entrée texte. Le formulaire est envoyé via `onSubmit`, ses inputs gardent donc leurs valeurs quand le serveur répond par des erreurs.

`src/routes/beta.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";

const applyForBeta = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return Object.fromEntries([...data].filter((entry): entry is [string, string] => typeof entry[1] === "string"));
  })
  .handler(async ({ data: answers }) => {
    const origin = getRequestHeader("origin");
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(answers),
    });
    const result = await response.json();
    if (result.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} };
    return { ok: false, message: String(result.error.message), fields: (result.error.fields ?? {}) as Record<string, string[]> };
  });

export const Route = createFileRoute("/beta")({ component: Beta });

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

function Beta() {
  const applyFn = useServerFn(applyForBeta);
  const [state, setState] = useState<{ ok: boolean; message: string; fields: Record<string, string[]> } | null>(null);
  const [pending, setPending] = useState(false);
  const errors = state?.fields ?? {};

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      setState(await applyFn({ data: new FormData(event.currentTarget) }));
    } catch {
      setState({ ok: false, message: "Couldn't send your application. Try again.", fields: {} });
    } finally {
      setPending(false);
    }
  }

  if (state?.ok) return <p role="status">{state.message}</p>;

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <FieldError messages={errors.email} />
      <label htmlFor="role">Your role</label>
      <select id="role" name="role" required>
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="product">Product manager</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
      <FieldError messages={errors.use_case} />
      <label htmlFor="current_tool">What do you use today?</label>
      <input id="current_tool" name="current_tool" maxLength={120} />
      <label>
        <input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call
      </label>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state && !Object.keys(errors).length ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
    </form>
  );
}
```

**React Router**

L'`action` de la route transmet l'envoi et conserve le statut d'Ikiform avec `data()`. `<Form>` envoie sans rechargement, les réponses restent donc en place.

`app/routes/beta.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/beta";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
  const origin = 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 ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(answers),
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} };
  return data(
    { ok: false, message: String(result.error.message), fields: (result.error.fields ?? {}) as Record<string, string[]> },
    { status: response.status },
  );
}

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

export default function Beta({ actionData }: Route.ComponentProps) {
  const pending = useNavigation().state === "submitting";
  const errors: Record<string, string[]> = actionData?.fields ?? {};

  if (actionData?.ok) return <p role="status">{actionData.message}</p>;

  return (
    <Form method="post">
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <FieldError messages={errors.email} />
      <label htmlFor="role">Your role</label>
      <select id="role" name="role" required>
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="product">Product manager</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
      <FieldError messages={errors.use_case} />
      <label htmlFor="current_tool">What do you use today?</label>
      <input id="current_tool" name="current_tool" maxLength={120} />
      <label>
        <input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call
      </label>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData && !Object.keys(errors).length ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
    </Form>
  );
}
```

**SvelteKit**

`fail()` renvoie les erreurs de champ à `form`. `use:enhance` ne réinitialise le formulaire qu'après un succès : une candidature refusée garde donc ses réponses. Déclarez `IKIFORM_SECRET_KEY` dans `src/env.ts` avec `defineEnvVars`, comme dans la [liste d'attente SvelteKit](https://ikiform.com/fr/guides/waitlist/sveltekit.md#env), pour que `$app/env/private` puisse la lire, et transmettez l'`Origin` du navigateur pour que les tests en local comptent comme des envois gratuits depuis `localhost`.

`src/routes/beta/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request, fetch, url }) => {
    const formData = await request.formData();
    const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
    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: request.headers.get("origin") ?? url.origin,
      },
      body: JSON.stringify(answers),
    });
    const result = await response.json();
    if (!result.ok) {
      return fail(response.status, {
        message: String(result.error.message),
        fields: (result.error.fields ?? {}) as Record<string, string[]>,
      });
    }
    return { applied: true };
  },
} satisfies Actions;
```

`src/routes/beta/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
</script>

{#if form?.applied}
  <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
{:else}
  <form method="POST" use:enhance>
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {#if form?.fields?.email}<p role="alert">{form.fields.email[0]}</p>{/if}
    <label for="role">Your role</label>
    <select id="role" name="role" required>
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="product">Product manager</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <fieldset>
      <legend>Team size</legend>
      {#each sizes as [size, label]}
        <label><input type="radio" name="team_size" value={size} required /> {label}</label>
      {/each}
    </fieldset>
    <label for="use_case">What would you use it for first?</label>
    <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>
    {#if form?.fields?.use_case}<p role="alert">{form.fields.use_case[0]}</p>{/if}
    <label for="current_tool">What do you use today?</label>
    <input id="current_tool" name="current_tool" maxlength="120" />
    <label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message && !form?.fields?.email && !form?.fields?.use_case}<p role="alert">{form.message}</p>{/if}
    <button>Apply for the beta</button>
  </form>
{/if}
```

**Nuxt**

La route serveur lit le `FormData` avec `readFormData` et transmet le statut d'Ikiform. `ignoreResponseError` fait renvoyer à `$fetch` le corps de l'erreur au lieu de lever une exception.

`server/api/beta.post.ts`:

```ts
export default defineEventHandler(async (event) => {
  const formData = await readFormData(event);
  const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
  const { ikiformSecretKey } = 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 ${ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(answers),
  });
  const result = await response.json();
  setResponseStatus(event, response.status);
  return result.ok
    ? { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} }
    : { ok: false, message: String(result.error.message), fields: result.error.fields ?? {} };
});
```

`app/components/BetaForm.vue`:

```vue
<script setup lang="ts">
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
const state = ref<{ ok: boolean; message: string; fields: Record<string, string[]> } | null>(null);
const pending = ref(false);

async function apply(event: SubmitEvent) {
  pending.value = true;
  try {
    state.value = await $fetch("/api/beta", {
      method: "POST",
      body: new FormData(event.target as HTMLFormElement),
      ignoreResponseError: true,
    });
  } catch {
    state.value = { ok: false, message: "Couldn't send your application. Try again.", fields: {} };
  }
  pending.value = false;
}
</script>

<template>
  <p v-if="state?.ok" role="status">{{ state.message }}</p>
  <form v-else method="post" @submit.prevent="apply">
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <p v-if="state?.fields.email" role="alert">{{ state.fields.email[0] }}</p>
    <label for="role">Your role</label>
    <select id="role" name="role" required>
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="product">Product manager</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <fieldset>
      <legend>Team size</legend>
      <label v-for="[size, label] in sizes" :key="size">
        <input type="radio" name="team_size" :value="size" required /> {{ label }}
      </label>
    </fieldset>
    <label for="use_case">What would you use it for first?</label>
    <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required />
    <p v-if="state?.fields.use_case" role="alert">{{ state.fields.use_case[0] }}</p>
    <label for="current_tool">What do you use today?</label>
    <input id="current_tool" name="current_tool" maxlength="120" />
    <label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button :disabled="pending">{{ pending ? "Sending" : "Apply for the beta" }}</button>
  </form>
</template>
```

**React**

Une application monopage envoie directement à Ikiform depuis le navigateur : il n'y a donc pas de clé secrète dans ce code. Gardez **Allowed domains** réglé sur votre site.

`src/BetaForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

export function BetaForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "applied">("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    setMessage("");
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) return setStatus("applied");
      setErrors(result.error.fields ?? {});
      setMessage(result.error.message);
    } catch {
      setMessage("Couldn't reach the server. Try again.");
    }
    setStatus("idle");
  }

  if (status === "applied") return <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <FieldError messages={errors.email} />
      <label htmlFor="role">Your role</label>
      <select id="role" name="role" required>
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="product">Product manager</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
      <FieldError messages={errors.use_case} />
      <label htmlFor="current_tool">What do you use today?</label>
      <input id="current_tool" name="current_tool" maxLength={120} />
      <label>
        <input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call
      </label>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {message && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Apply for the beta"}</button>
    </form>
  );
}
```

**Vue**

Le même envoi côté navigateur dans un composant Vue. Le formulaire garde son `action`, il envoie donc même si le script échoue.

`src/components/BetaForm.vue`:

```vue
<script setup lang="ts">
import { ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
const status = ref<"idle" | "sending" | "applied">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");

async function apply(event: SubmitEvent) {
  status.value = "sending";
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    if (result.ok) {
      status.value = "applied";
      return;
    }
    errors.value = result.error.fields ?? {};
    message.value = result.error.message;
  } catch {
    message.value = "Couldn't reach the server. Try again.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'applied'" role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="apply">
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
    <label for="role">Your role</label>
    <select id="role" name="role" required>
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="product">Product manager</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <fieldset>
      <legend>Team size</legend>
      <label v-for="[size, label] in sizes" :key="size">
        <input type="radio" name="team_size" :value="size" required /> {{ label }}
      </label>
    </fieldset>
    <label for="use_case">What would you use it for first?</label>
    <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required />
    <p v-if="errors.use_case" role="alert">{{ errors.use_case[0] }}</p>
    <label for="current_tool">What do you use today?</label>
    <input id="current_tool" name="current_tool" maxlength="120" />
    <label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="message && !Object.keys(errors).length" role="alert">{{ message }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Apply for the beta" }}</button>
  </form>
</template>
```

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

## Choisir une cohorte

Schéma : Comment les candidatures deviennent des cohortes de bêta

1. Les candidatures arrivent complètes, car Ikiform refuse les envois qui sautent une question obligatoire.
2. Un script les lit via l'API et note chacune selon le rôle, la taille de l'équipe, la longueur du cas d'usage et la case d'appel.
3. Les 25 premiers forment la première cohorte et reçoivent une invitation depuis votre outil d'e-mailing.
4. Deux semaines plus tard, relancez le script avec les e-mails déjà invités, pour qu'ils soient ignorés, et choisissez le groupe suivant.
5. Tous ceux sous la barre restent sur la liste et auront de vos nouvelles quand viendra leur tour.

Les notes sont un ordre de départ, pas un verdict. Lisez vous-même le cas d'usage de tous ceux qui sont proches de la barre.

Le script lit chaque candidature reçue avec l'[API REST](https://ikiform.com/fr/docs/api.md#list-submissions), garde la plus récente par e-mail, la note et écrit le groupe de tête dans `cohort.csv`. Passez en arguments les e-mails déjà invités pour que le tour suivant les ignore. Il lui faut une clé secrète avec le scope `submissions:read`.

`cohort.mjs`:

```js
import { writeFileSync } from "node:fs";
import { createClient } from "@ikiform/sdk";

const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY });
const COHORT_SIZE = 25;
const invited = new Set(process.argv.slice(2).map((email) => email.toLowerCase()));

const ROLE = { founder: 3, engineer: 3, product: 2, designer: 1, other: 0 };
const SIZE = { "2-10": 3, "11-50": 2, "1": 1, "51+": 1 };

const people = new Map();
for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx", status: "received", limit: 100 })) {
  const answers = submission.fields;
  const email = String(answers.email ?? "").trim().toLowerCase();
  if (!email || invited.has(email) || people.has(email)) continue;
  const words = String(answers.use_case ?? "").split(/\s+/).length;
  const score =
    (ROLE[answers.role] ?? 0) +
    (SIZE[answers.team_size] ?? 0) +
    Math.min(3, Math.floor(words / 25)) +
    (answers.interview === true ? 2 : 0) +
    (answers.current_tool ? 1 : 0);
  people.set(email, { email, score, role: answers.role, team_size: answers.team_size });
}

const ranked = [...people.values()].sort((a, b) => b.score - a.score);
const cohort = ranked.slice(0, COHORT_SIZE);
const quote = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const rows = cohort.map((person) => [person.email, person.score, person.role, person.team_size].map(quote).join(","));
writeFileSync("cohort.csv", ["email,score,role,team_size", ...rows].join("\n"));
console.log(`${ranked.length} applicants, ${cohort.length} in this cohort, lowest score ${cohort.at(-1)?.score}`);
```

- Adaptez `ROLE` et `SIZE` au public de votre première version. Un outil d'équipe pourrait mieux noter `2-10` ; un outil individuel, `1`.
- Le cas d'usage rapporte un point par tranche de 25 mots, jusqu'à 3. La longueur n'est pas la qualité, mais quelqu'un qui écrit trois phrases est généralement au rendez-vous.
- Mettre chaque valeur entre guillemets évite que des virgules dans les réponses ne cassent le CSV.
- Rangez la liste des invités dans un endroit où vous la retrouverez, comme un `invited.txt` que vous passez avec `$(cat invited.txt)`.

### Envoyer l'invitation

Importez `cohort.csv` dans votre outil d'e-mailing et envoyez l'invitation depuis celui-ci. Ikiform n'envoie pas d'e-mail à la personne qui remplit le formulaire. Les e-mails de notification vont uniquement à vous et à votre équipe : il n'y a donc ni réponse automatique, ni e-mail de confirmation, ni double opt-in. Envoyez-les depuis votre outil d'e-mailing, déclenché par un [webhook](https://ikiform.com/fr/docs/webhooks.md). Gardez la première invitation courte : ce qu'ils obtiennent, la chose que vous voulez qu'ils essaient, et comment vous joindre. Chaque e-mail de notification qu'Ikiform vous envoie a un bouton **Reply** qui écrit au candidat, ce qui convient à un petit mot personnel aux personnes proches de la barre.

## Vérifiez que tout fonctionne

1. Dans **Allowed domains** du formulaire, ajoutez le domaine de votre site, plus `localhost` pendant le développement. Laissez vide pour accepter les envois de partout.
2. Collez la définition des champs dans l'onglet **Fields** du formulaire et enregistrez-la.
3. Envoyez une candidature avec un cas d'usage d'une ligne. Le formulaire affiche le message de longueur minimale sous ce champ, et rien n'est stocké.
4. Envoyez une candidature complète. Elle apparaît sous **Submissions** avec toutes les réponses, et `interview` vaut `true` ou `false`.
5. Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et **Reply** répond à l'expéditeur quand le formulaire a un champ `email`.
6. 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.
7. Lancez le script de notation avec une clé secrète et vérifiez que `cohort.csv` liste votre candidature de test avec sa note.

## Pièges

| Problème | Que faire |
| --- | --- |
| `422 validation_failed` | L'envoi ne correspond pas à la définition des champs du formulaire. `error.fields` nomme chaque champ et la raison. Faites correspondre le `name` de l'input à l'id du champ. |
| `422` avec Unknown field pour `ref`, `utm_source` ou un autre input masqué | La définition des champs ne le liste pas. Ajoutez-le comme champ `text` facultatif, ou renommez-le avec un `_` initial pour qu'Ikiform l'ignore. |
| Une longue réponse disparaît après une erreur dans Next.js | React réinitialise le formulaire après une server action. Renvoyez les réponses depuis l'action et définissez `defaultValue` sur chaque input, comme le fait l'exemple Next.js. |
| Le script de notation lève `401 unauthorized` ou `403 forbidden` | Un `401` signifie que la clé est absente ou révoquée ; un `403` qu'elle est publiable ou n'a pas le scope. Utilisez une clé secrète (`ik_sk_`) avec le scope `submissions:read`. |
| La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivé | Elle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre **Spam** pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks. |

## Questions

### Combien de questions doit avoir un formulaire d'inscription bêta ?

Trois à six. Chaque question doit changer qui vous invitez. Les choix fermés obligatoires, comme le rôle et la taille de l'équipe, se trient bien ; une question ouverte obligatoire avec une longueur minimale vous dit qui utilisera la bêta.

### Ikiform peut-il rejeter les candidatures qui sautent une question de sélection ?

Oui. Définissez les champs dans l'onglet Fields du formulaire et marquez-les comme obligatoires. Un envoi qui en omet un est refusé avec 422 validation_failed, et l'erreur nomme chaque champ, pour que votre formulaire affiche le message au bon endroit.

### Puis-je marquer des candidats comme invités dans Ikiform ?

Non. Ikiform n'a ni tags ni statuts personnalisés. Gardez la liste des invités là où vous envoyez les invitations, ou dans un fichier que vous passez au script de notation, comme montré ci-dessus.

### Ikiform supprime-t-il les soumissions en double ?

Non. Chaque envoi accepté est stocké. Une répétition identique dans les 24 heures augmente le score de spam mais n'est pas du spam en soi : dédoublonnez donc par e-mail dans votre liste, votre base de données ou votre export CSV.

### 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 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.
- [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 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 sur Framer](https://ikiform.com/fr/guides/waitlist/framer.md): Deux façons de collecter des inscriptions sur Framer : le formulaire Framer avec un webhook vers Ikiform, ou un code component qui garde limites et anti-spam.
- [Liste d'attente sur Webflow](https://ikiform.com/fr/guides/waitlist/webflow.md): Faites pointer un Form Block Webflow vers Ikiform, taguez les inscriptions par source avec un champ masqué et gardez l'état de succès de Webflow avec un court script.
