Liste d'attente pour un nouveau SaaS

Liste d'attente pour un nouveau SaaS7 min de lecture

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.

Preet SutharFondateur, Ikiform

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

ItemLe navigateur envoie à IkiformServer action avec une clé secrète
Ce que voit IkiformL'IP de chaque visiteurL'IP de votre serveur, pour chaque visiteur
Limite de débit par visiteurCelle d'Ikiform, par IPÀ ajouter vous-même, voir ci-dessous
Domaines autorisés et vérification humaineAppliquésIgnorés, car la clé prouve que l'envoi vient de vous
Qui peut l'appelerN'importe qui, depuis vos domaines autorisésN'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.

Le parcours d'une inscription à travers la server action Next.jsformulaireuseOptimisticaffiche lemerciserveractionvérifiel'e-mail,limite parIPIkiformclé secrète,stockel'inscriptionétat del'actioninscrit, ouune erreurétatd'erreurrien n'estenvoyé, leformulairerevientFormDataJSONrésultatau-delà de la limiteLe parcours d'une inscription à travers la server action Next.jsformulaireuseOptimistic affichele merciserver actionvérifie l'e-mail,limite par IPIkiformclé secrète, stockel'inscriptionétat de l'actioninscrit, ou une erreurétatd'erreurrien n'estenvoyé, leformulairerevientFormDataJSONrésultatau-delà de la limite
  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
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.

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
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
"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
"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
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èmeQue faire
403 forbidden de la part d'IkiformLa 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_limitedUn 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 tempsL'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 messageL'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 transitionshowJoined a été appelé depuis un gestionnaire d'événement. Appelez-le dans l'action du formulaire, comme le fait join.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.