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.
É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 :
| Item | 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.
- Le visiteur clique sur le bouton. useOptimistic remplace aussitôt le formulaire par la phrase de remerciement.
- La server action vérifie l'e-mail et compte les tentatives récentes du visiteur par IP.
- Dans la limite, elle envoie l'inscription à Ikiform avec la clé secrète.
- 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.
- Au-delà de la limite, rien n'est envoyé et l'état d'erreur revient immédiatement.
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_.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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.
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, lisezcf-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
"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.timeoutempêche un réseau lent de laisser le bouton indéfiniment en attente.- Le
429d'Ikiform est traité à part, avec sonRetry-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
defaultValueremet l'adresse en place. - L'en-tête
Origindu navigateur est transmis car Ikiform s'en sert pour compter les soumissions de test gratuites, et lefetchd'un serveur n'en envoie aucun. - Le
submit()du SDK n'envoie pas d'en-têteAuthorization, il ne peut donc pas contourner les domaines autorisés. Utilisez icifetchavec la clé.
Le formulaire, avec un remerciement optimiste
"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>
);
}useOptimisticdoit être défini à l'intérieur d'une action.joinest l'action du formulaire : React l'exécute dans une transition et la phrase optimiste tient jusqu'à la fin dedispatch.- En cas de succès, l'état de l'action devient
joinedet la phrase reste, désormais avec le message final. En cas d'erreur, la valeur optimiste disparaît et le formulaire revient avecstate.emailet le message. aria-busyindique aux lecteurs d'écran que la phrase est encore en cours de confirmation.
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
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
- Lancez
next dev, ouvrez/waitlist?ref=testet inscrivez-vous. La phrase de remerciement s'affiche avant la fin de la requête. - Ouvrez Submissions dans le tableau de bord. L'inscription y est, avec
refàtest. - Envoyez six fois en une minute. Le sixième envoi affiche le message de limite, et seules cinq soumissions sont stockées.
- Mettez une valeur erronée dans
IKIFORM_SECRET_KEYet envoyez. La phrase optimiste disparaît et le formulaire affiche l'erreur avec votre e-mail toujours dedans. - 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. - 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
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.
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.
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.
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.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.