# Comment créer une liste d'attente Next.js avec une server action

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

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

Écrivez une server action qui vérifie l'e-mail, applique une limite par visiteur et transmet l'inscription à votre backend de formulaires avec une clé secrète. Appelez-la via `useActionState` pour que les erreurs reviennent sous forme d'état, et enveloppez-la avec `useOptimistic` pour que la phrase de remerciement s'affiche dès le clic sur le bouton et disparaisse si l'envoi échoue.

Le [guide principal](https://ikiform.com/fr/guides/waitlist.md#form) a un court onglet Next.js : une server action et `useActionState`. Cela suffit pour une landing page discrète. Cette page va plus loin, pour une liste d'attente que vous allez partager largement : un remerciement instantané, des messages d'erreur honnêtes, et une limite par visiteur qu'un envoi côté serveur perd autrement.

## Ce que change une server action

Une server action garde la clé secrète sur votre serveur et vous permet de vérifier l'e-mail avant que quoi que ce soit ne quitte votre app. Elle fait aussi passer chaque envoi par l'adresse IP de votre serveur, ce qui a trois effets :

|   | Le navigateur envoie à Ikiform | Server action avec une clé secrète |
| --- | --- | --- |
| Ce que voit Ikiform | L'IP de chaque visiteur | L'IP de votre serveur, pour chaque visiteur |
| Limite de débit par visiteur | Celle d'Ikiform, par IP | À ajouter vous-même, voir ci-dessous |
| Domaines autorisés et vérification humaine | Appliqués | Ignorés, car la clé prouve que l'envoi vient de vous |
| Qui peut l'appeler | N'importe qui, depuis vos domaines autorisés | N'importe qui pouvant faire un POST sur votre page |

Cette dernière ligne compte. Next.js traite une server action comme un endpoint public : n'importe qui peut l'appeler avec un POST forgé, pas seulement votre formulaire. Comme la clé secrète contourne les vérifications d'origine d'Ikiform, votre action est le seul garde-fou : elle a donc besoin de sa propre limite.

Schéma : Le parcours d'une inscription à travers la server action Next.js

1. Le visiteur clique sur le bouton. useOptimistic remplace aussitôt le formulaire par la phrase de remerciement.
2. La server action vérifie l'e-mail et compte les tentatives récentes du visiteur par IP.
3. Dans la limite, elle envoie l'inscription à Ikiform avec la clé secrète.
4. Le résultat devient l'état de l'action. En cas de succès, le remerciement reste ; en cas d'erreur, le formulaire revient avec le message et l'e-mail saisi.
5. Au-delà de la limite, rien n'est envoyé et l'état d'erreur revient immédiatement.

La phrase optimiste ne dure que le temps de l'action. C'est l'état de l'action qui décide de ce qui reste à l'écran.

## La construire

### La clé secrète

Dans le tableau de bord, ouvrez **API keys**, créez une clé secrète avec le scope `submissions:write`, et stockez-la dans une variable réservée au serveur. Ne la préfixez jamais par `NEXT_PUBLIC_`.

`.env.local`:

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

### Une limite par visiteur

Cinq inscriptions par minute et par IP, c'est généreux pour une personne et inutile pour un script. Ce compteur vit dans la mémoire du serveur. `server-only` fait échouer le build si un composant client l'importe.

`lib/rate-limit.ts`:

```ts
import "server-only";

const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();

export function allow(key: string, now = Date.now()) {
  const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
  const allowed = recent.length < LIMIT;
  if (allowed) recent.push(now);
  if (hits.size > 10_000) hits.clear();
  hits.set(key, recent);
  return allowed;
}
```

- La mémoire n'est pas partagée entre les instances de serveur. Sur un seul serveur Node ou conteneur, c'est exact. En hébergement serverless, chaque instance a son propre compteur : voyez-le comme un ralentisseur, et déplacez le compteur vers un stockage partagé comme Redis quand le trafic augmente.
- Ne faites confiance qu'à l'en-tête d'IP que définit votre hébergeur. Vercel définit `x-forwarded-for` ; derrière Cloudflare, lisez `cf-connecting-ip`. Un en-tête que le visiteur peut définir lui-même n'est pas une limite du tout.

### La server action

`app/waitlist/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";

export interface JoinState {
  status: "idle" | "joined" | "error";
  message: string;
  email?: string;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };

  const incoming = await headers();
  const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
  if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };

  let response: Response;
  try {
    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: incoming.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't reach the list. Try again.", email };
  }

  if (response.status === 429) {
    const wait = response.headers.get("Retry-After") ?? "60";
    return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
  }
  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
  return {
    status: "error",
    message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
    email,
  };
}
```

- La vérification peu coûteuse passe en premier. Un e-mail invalide ne coûte jamais de requête.
- `AbortSignal.timeout` empêche un réseau lent de laisser le bouton indéfiniment en attente.
- Le `429` d'Ikiform est traité à part, avec son `Retry-After`, car avec tous les visiteurs derrière une seule IP, c'est le formulaire entier qui est saturé, pas cette personne.
- Chaque erreur renvoie l'e-mail, pour que l'input puisse l'afficher à nouveau. React réinitialise un formulaire après une action, et `defaultValue` remet l'adresse en place.
- L'en-tête `Origin` du navigateur est transmis car Ikiform s'en sert pour compter les soumissions de test gratuites, et le `fetch` d'un serveur n'en envoie aucun.
- Le `submit()` du SDK n'envoie pas d'en-tête `Authorization`, il ne peut donc pas contourner les domaines autorisés. Utilisez ici `fetch` avec la clé.

### Le formulaire, avec un remerciement optimiste

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

```tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";

const initial: JoinState = { status: "idle", message: "" };

export function WaitlistForm({ source }: { source: string }) {
  const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
  const [shown, showJoined] = useOptimistic(
    state,
    (_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
  );

  function join(formData: FormData) {
    showJoined(String(formData.get("email") ?? ""));
    dispatch(formData);
  }

  if (shown.status === "joined") {
    return (
      <p role="status" aria-busy={pending}>
        {shown.message}
      </p>
    );
  }

  return (
    <form action={join}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
      <input type="hidden" name="ref" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
      <button disabled={pending}>Join the waitlist</button>
    </form>
  );
}
```

- `useOptimistic` doit être défini à l'intérieur d'une action. `join` est l'action du formulaire : React l'exécute dans une transition et la phrase optimiste tient jusqu'à la fin de `dispatch`.
- En cas de succès, l'état de l'action devient `joined` et la phrase reste, désormais avec le message final. En cas d'erreur, la valeur optimiste disparaît et le formulaire revient avec `state.email` et le message.
- `aria-busy` indique aux lecteurs d'écran que la phrase est encore en cours de confirmation.

> Envelopper l'action dans `join` en fait une fonction client : ce formulaire a donc besoin de JavaScript. Si envoyer avant l'hydratation compte plus que le remerciement instantané, passez directement `dispatch` à `action` et retirez `useOptimistic` ; le reste du code ne change pas.

### La page

`app/waitlist/page.tsx`:

```tsx
import { WaitlistForm } from "./waitlist-form";

export default async function Page({ searchParams }: PageProps<"/waitlist">) {
  const { ref } = await searchParams;
  const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";

  return (
    <main>
      <h1>Acme is almost here</h1>
      <p>Leave your email and we'll send you an invite the day it opens.</p>
      <WaitlistForm source={source} />
    </main>
  );
}
```

La page lit `?ref=` dans l'URL et le transmet au formulaire : un lien comme `/waitlist?ref=newsletter` tague donc chaque inscription avec sa source. `PageProps` est le type lié à la route que génère Next.js ; lire `searchParams` rend la page à chaque requête.

## Vérifiez que tout fonctionne

1. Lancez `next dev`, ouvrez `/waitlist?ref=test` et inscrivez-vous. La phrase de remerciement s'affiche avant la fin de la requête.
2. Ouvrez **Submissions** dans le tableau de bord. L'inscription y est, avec `ref` à `test`.
3. Envoyez six fois en une minute. Le sixième envoi affiche le message de limite, et seules cinq soumissions sont stockées.
4. Mettez une valeur erronée dans `IKIFORM_SECRET_KEY` et envoyez. La phrase optimiste disparaît et le formulaire affiche l'erreur avec votre e-mail toujours dedans.
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.

## Pièges

| Problème | Que faire |
| --- | --- |
| `403 forbidden` de la part d'Ikiform | La clé vient d'un autre espace de travail, est révoquée, ou n'a pas `submissions:write`. Créez une clé secrète avec ce scope. |
| `429 rate_limited` | Un visiteur a envoyé plus que ce que permet la limite de débit du formulaire. Standard autorise 5 envois par minute et par visiteur. Attendez la durée de `Retry-After`, ou choisissez Relaxed ou Custom pour les formulaires qui envoient depuis une seule IP partagée. |
| Tous les visiteurs atteignent la limite en même temps | L'en-tête d'IP est absent, donc tout le monde compte comme `unknown`. Lisez l'en-tête que définit votre hébergeur, comme `cf-connecting-ip` derrière Cloudflare. |
| Le remerciement clignote et le formulaire revient sans message | L'action a levé une exception au lieu de renvoyer un état. Faites de chaque cas d'échec un `JoinState` renvoyé, comme dans l'exemple. |
| Un avertissement indiquant qu'une mise à jour optimiste a eu lieu hors d'une transition | `showJoined` a été appelé depuis un gestionnaire d'événement. Appelez-le dans l'action du formulaire, comme le fait `join`. |

## Questions

### Une liste d'attente Next.js doit-elle envoyer depuis le navigateur ou depuis une server action ?

Les deux fonctionnent. Un envoi depuis le navigateur garde les limites par visiteur, les domaines autorisés et la vérification humaine d'Ikiform. Une server action masque l'endpoint, vérifie d'abord les entrées et fonctionne avant l'hydratation, mais vous ajoutez votre propre limite par visiteur car chaque envoi vient de votre serveur.

### useOptimistic fonctionne-t-il avec useActionState ?

Oui. Définissez la valeur optimiste et appelez la fonction dispatch de useActionState dans la même action, comme la prop action du formulaire. La valeur optimiste s'affiche pendant l'exécution de l'action et cède la place à l'état de l'action quand elle se termine.

### Ai-je besoin d'une base de données pour une liste d'attente Next.js ?

Non. La server action transmet chaque inscription à Ikiform, qui la stocke, filtre le spam, vous prévient par e-mail et envoie des webhooks. Votre app ne conserve aucun état en dehors du compteur de limite de débit.

### 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 en HTML simple](https://ikiform.com/fr/guides/waitlist/html.md): Une page de liste d'attente complète en HTML simple, sans build, qui fonctionne sans JavaScript, remercie sur place quand il se charge, et se replie si besoin.
- [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.
- [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.
