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.
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 :
| Item | Server function | Server route |
|---|---|---|
| Définie avec | createServerFn().handler() | createFileRoute() avec server.handlers |
| Appelée par | Vos composants, via un appel RPC | N'importe quel client HTTP, y compris un simple <form> |
| Réponse | Sérialisée pour l'appelant | Une Response que vous construisez |
| Sans JavaScript | Possible via son .url, mais vous ne maîtrisez pas la réponse | Au 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.
- La route de la landing page lit ?ref= via validateSearch et l'affiche dans un input masqué.
- Le formulaire envoie son FormData à la server route, avec fetch quand le script est chargé, ou en envoi classique sinon.
- La route vérifie l'e-mail et le tag de source, puis transmet l'inscription à Ikiform avec la clé secrète.
- 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.
- 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.
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 :
src/
routes/
__root.tsx
index.tsx
api/waitlist.ts
waitlist-messages.ts
vite.config.ts
.env.localStockez 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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 :
{
"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
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
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 : lemultipart/form-dataqu'envoiefetchavec un corpsFormData, et le corps urlencoded d'un envoi classique.process.envest lu dans le handler, au moment de la requête. Sur Cloudflare Workers, il ne contient vos secrets qu'avecnodejs_compatactivé et une date de compatibilité au 2025-04-01 ou plus tard, comme dans lewrangler.jsoncci-dessous.- L'en-tête
Origindu navigateur est transmis car Ikiform s'en sert pour compter les soumissions de test gratuites depuislocalhost, et lefetchde la route n'en envoie aucun. refdoit être composé de lettres minuscules, de chiffres, de-ou de_, jusqu'à 40 caractères, conformément aumax_lengthde la définition. Tout le reste est stocké commedirect.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
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>
);
}validateSearchtransforme la query brute en objet typé. Les valeurs inconnues deviennentundefined: un lien cassé affiche donc le formulaire simple.- L'input masqué
refest rempli côté serveur à partir des search params. Un envoi classique le transporte sans aucun script. - Le formulaire a une vraie
methodet une vraieaction. Si le bundle n'est pas chargé, le navigateur envoie vers la server route et suit la redirection. - Une fois hydraté,
onSubmitprend le relais et envoieAccept: 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 | À ajouter | Le secret |
|---|---|---|
| Vercel | Le plugin Vite nitro() du package nitro | Environment Variables dans les réglages du projet |
| Netlify | @netlify/vite-plugin-tanstack-start, sous la forme netlify() dans les plugins Vite | Site configuration › Environment variables (variables d'environnement) |
| Cloudflare Workers | @cloudflare/vite-plugin avec viteEnvironment: { name: "ssr" } et un wrangler.jsonc | wrangler secret put IKIFORM_SECRET_KEY |
{
"$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
- Lancez
vite dev, ouvrezhttp://localhost:3000/?ref=testet inscrivez-vous. La phrase de remerciement remplace le formulaire sans rechargement. - Ouvrez Submissions dans le tableau de bord. L'inscription y est, avec
refàtest. - Désactivez JavaScript, rechargez la même URL et inscrivez-vous à nouveau. La barre d'adresse affiche
?joined=yes, et la seconde inscription a aussirefàtest. - Avec JavaScript désactivé, envoyez
a@bet vérifiez que la page revient avec le message sur l'e-mail et?problem=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. - Les envois depuis
vite 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 |
|---|---|
| Un envoi classique affiche du JSON brut | Le 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 production | IKIFORM_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/waitlist | Start 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_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
Les deux peuvent transmettre une inscription. Une server route vous laisse choisir la réponse à un envoi de formulaire classique, comme une redirection vers la page, pour que le formulaire fonctionne avant l'hydratation. Une server function convient aux formulaires qui ne tournent qu'une fois React chargé.
Lisez ?ref= avec validateSearch sur la route de la landing page, affichez-le dans un input masqué, et vérifiez-le à nouveau côté serveur avant de le transmettre. La redirection après un envoi classique le remet dans l'URL.
Pas si elle affiche la source ou le résultat à partir de la query string, car une page pré-rendue est construite sans. Gardez cette route rendue côté serveur et pré-rendez le reste.
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.