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

Publié le 2026-10-25 · https://ikiform.com/fr/guides/waitlist/tanstack-start

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](https://ikiform.com/fr/guides/waitlist.md#form) 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 :

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

Schéma : Comment la server route TanStack Start répond à chaque type d'envoi

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](https://ikiform.com/fr/docs/guides/server.md) couvre les deux. Voici les fichiers concernés :

`Projet`:

```text
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`:

```bash
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](https://ikiform.com/fr/docs/rate-limits.md) 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`:

```json
{
  "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`:

```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`:

```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`:

```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 | À 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` |

`wrangler.jsonc`:

```json
{
  "$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](https://ikiform.com/fr/pricing).

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

### Un formulaire TanStack Start doit-il utiliser une server function ou une server route ?

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

### Comment conserver la source de l'inscription dans TanStack Start ?

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.

### La landing page TanStack Start peut-elle être pré-rendue ?

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.

### Ikiform est-il gratuit ?

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.

## Autres guides

- [Liste d'attente pour un nouveau SaaS](https://ikiform.com/fr/guides/waitlist.md): Créez une liste d'attente avant lancement sur votre landing page : le formulaire, l'anti-spam, les doublons, les e-mails de confirmation et un export propre le jour J.
- [Liste d'attente Next.js](https://ikiform.com/fr/guides/waitlist/nextjs.md): 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.
- [Liste d'attente React Router](https://ikiform.com/fr/guides/waitlist/react-router.md): 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.
- [Liste d'attente en HTML simple](https://ikiform.com/fr/guides/waitlist/html.md): Une page de liste d'attente complète en HTML simple, sans build, qui fonctionne sans JavaScript, remercie sur place quand il se charge, et se replie si besoin.
- [Liste de lancement Product Hunt](https://ikiform.com/fr/guides/waitlist/product-hunt.md): Collectez une liste des semaines avant Product Hunt, taguez chaque inscription par canal et envoyez l'e-mail du jour J en deux vagues calées sur l'heure du Pacifique.
- [Inscription bêta avec sélection](https://ikiform.com/fr/guides/waitlist/beta-signup.md): Un formulaire de candidature bêta avec des questions de sélection appliquées côté serveur, et un script qui note les candidats et choisit chaque cohorte.
