Liste d'attente pour un nouveau SaaS

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.

Preet SutharFondateur, Ikiform

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 envoieLa route courante, sauf si vous définissez actionL'action de la route /waitlist
Après l'envoiUne navigation. L'URL et l'historique changentPas de navigation. Le visiteur reste sur la page
Le résultatLe actionData de la route exécutéefetcher.data, partagé par tous les fetchers ayant la même key
Sans JavaScriptUn envoi normalUn 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.

Les inscriptions en pied de page de chaque page React Router passent par une seule action de route/pricingref = pricingfetcher.Formkey : waitlistaction/waitlisttransmet àIkiformfetcher.datamême page,en-tête mis àjour/blog/launchref =blog-launchformulairesimpleJavaScript nonchargéaction/waitlistle même codepage/waitlistactionDataPOSTPOSTpageformulaireactionrésultatLes inscriptions en pied de page de chaque page React Router passent par une seule action de route/pricingref = pricingfetcher.Formkey : waitlistaction /waitlisttransmet à Ikiformfetcher.datamême page, en-tête mis à jour/blog/launchref = blog-launchformulaire simpleJavaScript non chargéaction /waitlistle même codepage /waitlistactionDataPOSTPOST
  1. Le layout affiche le formulaire de liste d'attente sur chaque page, avec une ref masquée construite à partir du chemin de la page.
  2. 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.
  3. Sans JavaScript, le même formulaire envoie vers /waitlist comme une navigation normale.
  4. Dans les deux cas, l'action /waitlist vérifie l'e-mail et transmet l'inscription à Ikiform avec la clé secrète.
  5. Un fetcher lit le résultat dans fetcher.data. Un envoi classique arrive sur la page /waitlist, qui l'affiche depuis actionData.
Une action, autant de formulaires que vous voulez. Le chemin vous dit quelle page a valu l'inscription.

Configurer le projet

Le guide serveur couvre la création du formulaire et d'une clé secrète. Les fichiers de cette page :

Projet
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env

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

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

L'action envoie email et ref. Si le formulaire a une définition de champs dans son onglet Fields, elle doit lister les deux :

Champs
{
  "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

app/routes.ts
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

app/routes/waitlist.tsx
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 Origin du navigateur est transmis car Ikiform s'en sert pour compter les soumissions de test gratuites depuis localhost, et le fetch de l'action n'en envoie aucun.
  • ref doit être composé de lettres minuscules, de chiffres, de - ou de _, jusqu'à 40 caractères, le même max_length que la définition. Tout le reste est stocké comme direct.
  • 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é

app/components/waitlist-form.tsx
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é. JoinedNote dans l'en-tête lit le même fetcher.data : il change donc dès que le formulaire du pied de page réussit.
  • sourceFor transforme /blog/launch en blog-launch et / en home. Le chemin est connu côté serveur, cela fonctionne donc aussi sur les pages pré-rendues.
  • fetcher.state vaut submitting pendant l'envoi, puis loading pendant 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 :

app/routes/pricing.tsx
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 :

react-router.config.ts
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À ajouterLecture de la clé
Vercel@vercel/react-router, avec presets: [vercelPreset()] dans react-router.config.tsprocess.env, défini sous Environment Variables
Netlify@netlify/vite-plugin-react-router, sous la forme netlifyReactRouter() dans les plugins Viteprocess.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

  1. Lancez react-router dev, ouvrez http://localhost:5173/pricing et 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.
  2. Ouvrez Submissions dans le tableau de bord. L'inscription a ref à pricing.
  3. Désactivez JavaScript et inscrivez-vous depuis la page d'accueil. Vous arrivez sur /waitlist avec la phrase de remerciement, et l'inscription a ref à home.
  4. Envoyez a@b depuis le pied de page. L'erreur s'affiche sous l'input avec l'adresse toujours saisie.
  5. 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.
  6. Les envois depuis react-router dev portent une origine localhost : 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 depuis localhost. 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èmeQue 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 actionssr 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 waitlistLes 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_fieldLa définition des champs ne liste pas ref. Ajoutez-le comme dans la définition ci-dessus.
402 plan_requiredL'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

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