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

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

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

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

Schéma : Les inscriptions en pied de page de chaque page React Router passent par une seule action de route

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](https://ikiform.com/fr/docs/guides/server.md) couvre la création du formulaire et d'une clé secrète. Les fichiers de cette page :

`Projet`:

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

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

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

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

### Déclarer la route

`app/routes.ts`:

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

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

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

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

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

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](https://ikiform.com/fr/pricing).

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

### Comment envoyer un formulaire React Router sans navigation ?

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.

### Des pages React Router pré-rendues peuvent-elles avoir un formulaire fonctionnel ?

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.

### Comment savoir de quelle page vient une inscription React Router ?

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.

### 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 TanStack Start](https://ikiform.com/fr/guides/waitlist/tanstack-start.md): 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.
- [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.
