Liste d'attente pour un nouveau SaaS8 min de lecture
Créer une liste d'attente avec React Router
Une liste d'attente React Router en pied de chaque page : un fetcher envoie à une seule action, tague chaque inscription avec sa page et fonctionne sans JS.
Donnez à la liste d'attente sa propre route avec une action qui valide l'e-mail et le transmet à votre backend de formulaires avec une clé secrète. Affichez le formulaire dans votre layout avec useFetcher, pour qu'une inscription depuis n'importe quelle page reste sur cette page et soit taguée avec son chemin ; sans JavaScript, le même formulaire envoie vers la route de la liste d'attente, dont la page affiche le résultat.
Le guide principal a un onglet React Router avec le formulaire et son action sur la route d'accueil. Cela convient à un site d'une page. La plupart des sites SaaS avant lancement ont quelques pages, comme les tarifs, un article de blog et l'accueil, et vous voulez l'inscription sur toutes, et savoir laquelle a fonctionné. Cette page place le formulaire dans le layout et envoie chaque copie vers une seule action.
Ce qui change avec React Router
En mode framework, une action appartient à une route, et un <Form> envoie vers la route où il se trouve. Un formulaire dans le layout se trouve dans toutes les routes : le choix du composant détermine donc ce qui se passe à l'envoi :
| Item | <Form method="post"> | <fetcher.Form action="/waitlist"> |
|---|---|---|
| Où il envoie | La route courante, sauf si vous définissez action | L'action de la route /waitlist |
| Après l'envoi | Une navigation. L'URL et l'historique changent | Pas de navigation. Le visiteur reste sur la page |
| Le résultat | Le actionData de la route exécutée | fetcher.data, partagé par tous les fetchers ayant la même key |
| Sans JavaScript | Un envoi normal | Un envoi normal vers /waitlist, qui affiche sa propre page |
Un fetcher convient à une inscription en pied de page : personne ne veut quitter la page des tarifs pour rejoindre une liste. Il se dégrade aussi proprement. Avant l'hydratation, fetcher.Form est un <form> ordinaire qui envoie vers /waitlist : cette route a donc besoin de sa propre page pour afficher le résultat.
- Le layout affiche le formulaire de liste d'attente sur chaque page, avec une ref masquée construite à partir du chemin de la page.
- Avec JavaScript, fetcher.Form envoie vers /waitlist sans quitter la page. La key du fetcher est partagée : une mention dans l'en-tête voit donc le même résultat.
- Sans JavaScript, le même formulaire envoie vers /waitlist comme une navigation normale.
- Dans les deux cas, l'action /waitlist vérifie l'e-mail et transmet l'inscription à Ikiform avec la clé secrète.
- Un fetcher lit le résultat dans fetcher.data. Un envoi classique arrive sur la page /waitlist, qui l'affiche depuis actionData.
Configurer le projet
Le guide serveur couvre la création du formulaire et d'une clé secrète. Les fichiers de cette page :
app/
root.tsx
routes.ts
routes/home.tsx
routes/pricing.tsx
routes/waitlist.tsx
components/waitlist-form.tsx
react-router.config.ts
.envMettez la clé dans .env et lisez-la depuis process.env dans l'action. Les modules de route sont découpés pour le navigateur, mais action ne s'exécute que côté serveur : la clé n'est donc jamais envoyée au client. Ne l'importez jamais dans un composant.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.L'action envoie email et ref. Si le formulaire a une définition de champs dans son onglet Fields, elle doit lister les deux :
{
"version": 1,
"title": "Waitlist",
"fields": [
{ "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
{ "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
]
}La construire
Déclarer la route
import { index, type RouteConfig, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("pricing", "routes/pricing.tsx"),
route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;La route de la liste d'attente
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const email = String(form.get("email") ?? "").trim();
if (!EMAIL.test(email)) {
return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
}
const ref = String(form.get("ref") ?? "");
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: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: SOURCE.test(ref) ? ref : "direct",
_gotcha: String(form.get("_gotcha") ?? ""),
}),
signal: AbortSignal.timeout(8000),
}).catch(() => null);
const result = await response?.json().catch(() => null);
if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
return data(
{
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
},
{ status: response?.status === 429 ? 429 : 400 },
);
}
export default function Waitlist({ actionData }: Route.ComponentProps) {
return (
<main>
<h1>Join the Acme waitlist</h1>
<WaitlistForm initial={actionData} />
</main>
);
}data()définit le statut d'une erreur sans la transformer en réponse levée, pour que le composant affiche quand même le message.- Chaque réponse renvoie l'e-mail, pour que l'input puisse l'afficher à nouveau après une erreur.
- L'en-tête
Origindu navigateur est transmis car Ikiform s'en sert pour compter les soumissions de test gratuites depuislocalhost, et lefetchde l'action n'en envoie aucun. refdoit être composé de lettres minuscules, de chiffres, de-ou de_, jusqu'à 40 caractères, le mêmemax_lengthque la définition. Tout le reste est stocké commedirect.- L'export par défaut est le repli sans JavaScript. Il affiche le même formulaire avec le résultat de l'action.
Le formulaire partagé
import { useFetcher, useLocation } from "react-router";
interface Outcome {
ok: boolean;
message: string;
email: string;
}
export function sourceFor(pathname: string) {
const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
return (slug || "home").slice(0, 40);
}
export function WaitlistForm({ initial }: { initial?: Outcome }) {
const fetcher = useFetcher<Outcome>({ key: "waitlist" });
const { pathname } = useLocation();
const outcome = fetcher.data ?? initial;
const pending = fetcher.state !== "idle";
if (outcome?.ok) return <p role="status">{outcome.message}</p>;
return (
<fetcher.Form method="post" action="/waitlist">
<label>
Email
<input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
</label>
<input type="hidden" name="ref" value={sourceFor(pathname)} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
<p role="alert">{outcome?.message}</p>
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</fetcher.Form>
);
}
export function JoinedNote() {
const fetcher = useFetcher<Outcome>({ key: "waitlist" });
return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}key: "waitlist"fait de tous les fetchers ayant cette key un seul fetcher partagé.JoinedNotedans l'en-tête lit le mêmefetcher.data: il change donc dès que le formulaire du pied de page réussit.sourceFortransforme/blog/launchenblog-launchet/enhome. Le chemin est connu côté serveur, cela fonctionne donc aussi sur les pages pré-rendues.fetcher.statevautsubmittingpendant l'envoi, puisloadingpendant que React Router revalide : le bouton reste donc désactivé jusqu'à la fin des deux.- Le label englobe l'input, pour que les copies du hero et du pied de page sur une même page ne partagent pas un
id.
Affichez <WaitlistForm /> dans le Layout d'app/root.tsx, à l'intérieur d'un pied de page avec id="waitlist", et <JoinedNote /> dans votre en-tête. Le honeypot suit et arrive tel quel chez Ikiform. Les envois avec la clé secrète contournent la vérification humaine d'Ikiform : activer Spam check n'apporte donc rien pour ce formulaire.
Éviter le rechargement de tous les loaders
Après une action, React Router recharge les données de toutes les routes de la page. Une inscription n'en change aucune. Ajoutez ceci aux routes dont les loaders sont lents ou coûteux :
import type { ShouldRevalidateFunctionArgs } from "react-router";
export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
if (formAction === "/waitlist") return false;
return defaultShouldRevalidate;
}Rendu et déploiement
Les pages marketing ne changent pas selon le visiteur : pré-rendez-les et gardez le serveur pour l'action :
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
prerender: ["/", "/pricing"],
} satisfies Config;Gardez ssr: true. Avec ssr: false, il n'y a pas de serveur à l'exécution, et React Router fait échouer le build pour toute route qui exporte une action. Avec ssr: true, / et /pricing sont écrites en HTML au build, et /waitlist tourne toujours côté serveur.
| Hébergeur | À ajouter | Lecture de la clé |
|---|---|---|
| Vercel | @vercel/react-router, avec presets: [vercelPreset()] dans react-router.config.ts | process.env, défini sous Environment Variables |
| Netlify | @netlify/vite-plugin-react-router, sous la forme netlifyReactRouter() dans les plugins Vite | process.env, défini sous Environment variables |
| Cloudflare Workers | @cloudflare/vite-plugin, sous la forme cloudflare() avant reactRouter() | env.IKIFORM_SECRET_KEY depuis cloudflare:workers, défini avec wrangler secret put |
Sur Cloudflare, faites import { env } from "cloudflare:workers" et lisez-y la clé au lieu de process.env. Dans React Router 8, le context de l'action est un RouterContextProvider : le modèle context.cloudflare.env de la v7 ne s'applique plus. Le reste de l'action ne change pas.
Vérifiez que tout fonctionne
- Lancez
react-router dev, ouvrezhttp://localhost:5173/pricinget inscrivez-vous dans le pied de page. La phrase de remerciement remplace le formulaire, et la mention de l'en-tête change avec elle. - Ouvrez Submissions dans le tableau de bord. L'inscription a
refàpricing. - Désactivez JavaScript et inscrivez-vous depuis la page d'accueil. Vous arrivez sur
/waitlistavec la phrase de remerciement, et l'inscription arefàhome. - Envoyez
a@bdepuis le pied de page. L'erreur s'affiche sous l'input avec l'adresse toujours saisie. - 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. - Les envois depuis
react-router devportent une originelocalhost: 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 depuislocalhost. 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.
Pièges
| Problème | Que faire |
|---|---|
Une inscription en pied de page navigue vers /waitlist même avec JavaScript activé | Le formulaire est un <Form> ou un simple <form>, pas fetcher.Form. Utilisez le fetcher, et ne renvoyez pas de redirect() depuis l'action ; une redirection depuis l'action d'un fetcher provoque une navigation. |
Le build échoue avec une erreur sur les exports action | ssr vaut false dans react-router.config.ts. Réglez-le sur true, sinon la liste d'attente n'a pas de serveur sur lequel tourner. |
Le ref de toutes les inscriptions vaut waitlist | Les envois viennent de la page /waitlist elle-même. C'est la page de repli ; les liens doivent pointer vers vos vraies pages. |
422 avec unknown_field | La définition des champs ne liste pas ref. Ajoutez-le 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
Utilisez useFetcher et affichez fetcher.Form avec l'action de la route qui le traite. Le visiteur reste sur la page et le résultat arrive dans fetcher.data. Avant l'hydratation, il envoie comme un formulaire normal.
Oui, quand ssr vaut true. Les pages pré-rendues sont servies en HTML, et leurs formulaires envoient vers une action qui tourne côté serveur. Avec ssr à false, les actions ne sont pas autorisées.
Placez le chemin de la page dans un input masqué ref, comme le fait sourceFor ci-dessus. Le chemin est connu côté serveur et côté client : cela fonctionne donc sur les pages pré-rendues et avant l'hydratation.
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.