Liste d'attente pour un nouveau SaaS

Liste d'attente pour un nouveau SaaS8 min de lecture

Créer une liste d'attente avec TanStack Start

Une liste d'attente TanStack Start : une server route répond à un envoi classique comme à fetch, et validateSearch garde la source et le résultat dans l'URL.

Preet SutharFondateur, Ikiform

Ajoutez une server route à /api/waitlist qui valide l'e-mail, transmet l'inscription à votre backend de formulaires avec une clé secrète, et répond en JSON à fetch ou par une redirection 303 à un envoi de formulaire classique. La route de la landing page lit ?ref=, ?joined= et ?problem= via validateSearch : le tag de source et le résultat survivent donc avec ou sans JavaScript.

Le guide principal a un onglet TanStack Start basé sur createServerFn et useServerFn. Il est court, mais le formulaire ne fonctionne qu'une fois React hydraté, et la source de chaque inscription est une chaîne fixe. Cette page construit la version que vous partageriez le jour du lancement : elle envoie avant l'hydratation, tague chaque inscription d'après le lien d'origine, et garde le résultat dans l'URL.

Ce qui change avec TanStack Start

Start offre deux façons d'exécuter du code côté serveur, et elles se comportent différemment pour un formulaire :

ItemServer functionServer route
Définie aveccreateServerFn().handler()createFileRoute() avec server.handlers
Appelée parVos composants, via un appel RPCN'importe quel client HTTP, y compris un simple <form>
RéponseSérialisée pour l'appelantUne Response que vous construisez
Sans JavaScriptPossible via son .url, mais vous ne maîtrisez pas la réponseAu choix : JSON, ou une redirection vers la page

Un envoi de liste d'attente vient de deux types de clients : un navigateur qui exécute votre script, et un navigateur qui ne l'a pas encore chargé. Une server route peut répondre à chacun comme il l'attend, depuis un seul handler. L'en-tête Accept les distingue : le script demande application/json, un envoi de formulaire classique non.

Comment la server route TanStack Start répond à chaque type d'envoiroute delalandingpagevalidateSearchlit ?ref=POST/api/waitlistvérifiel'e-mail,transmetIkiformclésecrète,stockeJSONle scriptaffiche lerésultat303 vers/?joined=yesou?problem=emailroute delalandingpageaffiche lerésultatFormDataJSONAccept : jsonenvoi classiqueComment la server route TanStack Start répond à chaque type d'envoiroute de lalanding pagevalidateSearch lit?ref=POST/api/waitlistvérifie l'e-mail,transmetIkiformclé secrète, stockeJSONle script affiche lerésultat303 vers/?joined=yesou?problem=emailroute dela landingpageaffiche lerésultatFormDataJSONAccept : jsonenvoi classique
  1. La route de la landing page lit ?ref= via validateSearch et l'affiche dans un input masqué.
  2. Le formulaire envoie son FormData à la server route, avec fetch quand le script est chargé, ou en envoi classique sinon.
  3. La route vérifie l'e-mail et le tag de source, puis transmet l'inscription à Ikiform avec la clé secrète.
  4. Un envoi fetch a demandé du JSON : il reçoit donc { ok } ou un message, et le composant affiche la phrase de remerciement ou l'erreur.
  5. Un envoi classique reçoit un 303 vers la landing page, avec ?joined=yes ou un code ?problem=, et la même ref. validateSearch les lit, et la page affiche le résultat.
Un handler, deux réponses. L'URL transporte le résultat pour le visiteur sans JavaScript.

Configurer le projet

Créez d'abord le formulaire et une clé secrète ; le guide serveur couvre les deux. Voici les fichiers concernés :

Projet
src/
  routes/
    __root.tsx
    index.tsx
    api/waitlist.ts
  waitlist-messages.ts
vite.config.ts
.env.local

Stockez la clé dans .env.local, que Start charge en développement et qui reste hors de git. Le code serveur la lit depuis process.env. Ne lui donnez jamais de préfixe VITE_ : cela l'inclurait dans le code du navigateur.

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

La route envoie email et ref. Si vous ajoutez une définition de champs dans l'onglet Fields du formulaire, listez 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

Des codes d'erreur, pas du texte d'erreur

src/waitlist-messages.ts
export type Problem = "email" | "busy" | "failed";

export const MESSAGES: Record<Problem, string> = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

export function isProblem(value: unknown): value is Problem {
  return typeof value === "string" && value in MESSAGES;
}

La redirection place un code court dans l'URL, jamais le message lui-même. Un lien avec ?problem= ne peut alors afficher qu'une de vos trois phrases, pas un texte qu'un inconnu taperait dans une URL.

La server route

src/routes/api/waitlist.ts
import { createFileRoute } from "@tanstack/react-router";
import { MESSAGES, type Problem } from "../../waitlist-messages";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

async function forward(request: Request, form: FormData): Promise<Problem | null> {
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return "email";
  const ref = String(form.get("ref") ?? "");
  const origin = request.headers.get("origin");

  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 ? { Origin: origin } : {}),
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  if (!response) return "failed";
  if (response.status === 429) return "busy";
  const result = await response.json().catch(() => null);
  if (result?.ok) return null;
  return result?.error?.fields?.email ? "email" : "failed";
}

export const Route = createFileRoute("/api/waitlist")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const form = await request.formData();
        const problem = await forward(request, form);

        if (request.headers.get("accept")?.includes("application/json")) {
          return problem
            ? Response.json({ ok: false, message: MESSAGES[problem] }, { status: problem === "busy" ? 429 : 400 })
            : Response.json({ ok: true });
        }

        const back = new URL("/", request.url);
        back.searchParams.set("ref", String(form.get("ref") ?? ""));
        if (problem) back.searchParams.set("problem", problem);
        else back.searchParams.set("joined", "yes");
        return Response.redirect(back, 303);
      },
    },
  },
});
  • request.formData() lit les deux encodages : le multipart/form-data qu'envoie fetch avec un corps FormData, et le corps urlencoded d'un envoi classique.
  • process.env est lu dans le handler, au moment de la requête. Sur Cloudflare Workers, il ne contient vos secrets qu'avec nodejs_compat activé et une date de compatibilité au 2025-04-01 ou plus tard, comme dans le wrangler.jsonc ci-dessous.
  • 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 la route n'en envoie aucun.
  • ref doit être composé de lettres minuscules, de chiffres, de - ou de _, jusqu'à 40 caractères, conformément au max_length de la définition. Tout le reste est stocké comme direct.
  • Response.redirect(back, 303) indique au navigateur de charger la landing page en GET : un rafraîchissement ne renvoie donc pas l'inscription.

La route de la landing page

src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
import { isProblem, MESSAGES, type Problem } from "../waitlist-messages";

interface Search {
  ref?: string;
  joined?: boolean;
  problem?: Problem;
}

export const Route = createFileRoute("/")({
  validateSearch: (search: Record<string, unknown>): Search => ({
    ref: typeof search.ref === "string" ? search.ref.toLowerCase().slice(0, 40) : undefined,
    joined: search.joined === "yes" ? true : undefined,
    problem: isProblem(search.problem) ? search.problem : undefined,
  }),
  component: Home,
});

function Home() {
  const search = Route.useSearch();
  const [joined, setJoined] = useState(Boolean(search.joined));
  const [error, setError] = useState(search.problem ? MESSAGES[search.problem] : "");
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setError("");
    try {
      const response = await fetch("/api/waitlist", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const outcome = await response.json();
      if (outcome.ok) setJoined(true);
      else setError(outcome.message);
    } catch {
      setError("Couldn't reach the server. Try again.");
    } finally {
      setPending(false);
    }
  }

  if (joined) return <p role="status">You're on the list. We'll email you on launch day.</p>;

  return (
    <form method="post" action="/api/waitlist" onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required placeholder="you@company.com" />
      <input type="hidden" name="ref" value={search.ref ?? "direct"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{error}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </form>
  );
}
  • validateSearch transforme la query brute en objet typé. Les valeurs inconnues deviennent undefined : un lien cassé affiche donc le formulaire simple.
  • L'input masqué ref est rempli côté serveur à partir des search params. Un envoi classique le transporte sans aucun script.
  • Le formulaire a une vraie method et une vraie action. Si le bundle n'est pas chargé, le navigateur envoie vers la server route et suit la redirection.
  • Une fois hydraté, onSubmit prend le relais et envoie Accept: application/json, pour que le visiteur reste sur la page.

Le honeypot voyage dans le même FormData et arrive tel quel chez Ikiform : les bots qui le remplissent sont donc stockés comme spam. Les envois avec la clé secrète contournent la vérification humaine d'Ikiform : activer Spam check n'apporte donc rien ici ; c'est cette route qui fait office de garde-fou.

Rendu et déploiement

Laissez la route de la landing page rendue côté serveur, ce qui est le comportement par défaut de Start. Elle lit la query string pour afficher ref et le résultat, et une page pré-rendue est construite une fois, sans query. Si vous activez prerender dans le plugin tanstackStart() pour d'autres pages, laissez / de côté.

HébergeurÀ ajouterLe secret
VercelLe plugin Vite nitro() du package nitroEnvironment Variables dans les réglages du projet
Netlify@netlify/vite-plugin-tanstack-start, sous la forme netlify() dans les plugins ViteSite configuration › Environment variables (variables d'environnement)
Cloudflare Workers@cloudflare/vite-plugin avec viteEnvironment: { name: "ssr" } et un wrangler.jsoncwrangler secret put IKIFORM_SECRET_KEY
wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "acme-waitlist",
  "compatibility_date": "2025-09-02",
  "compatibility_flags": ["nodejs_compat"],
  "main": "@tanstack/react-start/server-entry"
}

nodejs_compat rend process.env disponible sur Workers. Sans lui, la clé est absente et chaque envoi échoue avec failed.

Vérifiez que tout fonctionne

  1. Lancez vite dev, ouvrez http://localhost:3000/?ref=test et inscrivez-vous. La phrase de remerciement remplace le formulaire sans rechargement.
  2. Ouvrez Submissions dans le tableau de bord. L'inscription y est, avec ref à test.
  3. Désactivez JavaScript, rechargez la même URL et inscrivez-vous à nouveau. La barre d'adresse affiche ?joined=yes, et la seconde inscription a aussi ref à test.
  4. Avec JavaScript désactivé, envoyez a@b et vérifiez que la page revient avec le message sur l'e-mail et ?problem=email.
  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 vite 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
Un envoi classique affiche du JSON brutLe navigateur a envoyé application/json dans son en-tête Accept, ce que ne font jamais les envois de formulaire normaux. Vérifiez qu'aucune extension ni aucun proxy ne le réécrit, ou faites envoyer à fetch un en-tête personnalisé sur lequel vous vous basez.
Chaque envoi répond failed en productionIKIFORM_SECRET_KEY n'est pas défini chez l'hébergeur. Sur Workers, vérifiez aussi que nodejs_compat est activé et que la date de compatibilité est au 2025-04-01 ou plus tard, sinon process.env reste vide.
Deux fichiers revendiquent /api/waitlistStart n'autorise qu'un fichier de handler par chemin. api/waitlist.ts et api/waitlist/index.ts y mènent tous deux ; n'en gardez qu'un.
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.