# Créer un formulaire de leads qualifiant avec React Router

> Un formulaire de leads React Router en mode framework : un middleware racine avec un cookie de premier contact signé, une action de route qui valide et une redirection vers votre outil de réservation.

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

Enregistrez les tags UTM et le referrer avec un middleware racine à la première requête de document, dans un cookie signé créé par `createCookie`. Validez le lead dans l'`action` de la route, transmettez-le avec l'attribution à votre backend de formulaires, et renvoyez `redirect()` vers votre outil de réservation pour les leads qualifiés, ou `data()` avec les erreurs de champ pour les autres. `<Form>` envoie avec ou sans JavaScript : la même action sert donc les deux cas.

L'[onglet React Router du guide principal](https://ikiform.com/fr/guides/lead-capture.md#form) transmet un lead depuis une action de route et affiche une ligne de remerciement. Cette page construit la version commerciale en mode framework : trois questions de qualification vérifiées côté serveur, la première campagne qui a amené le visiteur, et une redirection vers votre agenda pour les leads qui correspondent.

## Ce que change React Router

Les API de données de React Router ont été conçues autour des simples formulaires HTML. `<Form>` envoie à l'`action` de la route de la même façon avec ou sans JavaScript, et ce que renvoie l'action, `data()` avec des erreurs ou `redirect()`, fonctionne dans les deux cas. Avec JavaScript, React Router suit une redirection vers une autre origine par un chargement complet du document. Vous écrivez le chemin serveur une seule fois.

Deux autres éléments servent ici. Le middleware, activé par défaut depuis React Router 8, s'exécute à chaque requête de document : la route racine peut donc enregistrer le premier contact. Et `createCookie` signe sa valeur avec votre secret, pour qu'un visiteur ne puisse pas réécrire sa propre attribution avant qu'elle n'atteigne votre CRM.

Schéma : La même action React Router qui sert un lead avec et sans JavaScript

1. La première requête de document porte les tags de campagne dans l'URL et le referrer externe dans l'en-tête.
2. Le middleware racine laisse la page s'afficher, puis ajoute un Set-Cookie signé avec la source.
3. Avec JavaScript, Form envoie à l'action via une requête de données et React Router suit la redirection par un chargement complet de la page.
4. Sans JavaScript, le navigateur envoie nativement et suit lui-même le 303.
5. Dans les deux cas, un échec de validation renvoie des données avec le statut 400 et la page affiche les erreurs à côté des valeurs saisies.

Une seule action, deux transports. Le middleware ne s'exécute que sur les requêtes de document, et c'est là qu'a lieu le premier contact.

## Mise en place du projet

Partez d'une application en mode framework sous React Router 8. Ajoutez deux variables réservées au serveur : la clé secrète Ikiform et un secret pour signer le cookie. [L'envoi depuis un serveur](https://ikiform.com/fr/docs/guides/server.md) explique comment créer la clé.

`fichiers`:

```text
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-string
```

Ajoutez ensuite les champs dans l'onglet **Fields** du formulaire. Ikiform refait les mêmes vérifications et rejette les clés qu'il ne connaît pas.

| Id du champ | Type dans Ikiform | Renseigné par |
| --- | --- | --- |
| `name`, `email`, `company` | Texte, e-mail, texte | Le visiteur |
| `company_size` | Select : `1_10`, `11_50`, `51_200`, `201_1000`, `1000_plus` | Le visiteur |
| `budget` | Select : `under_10k`, `10k_50k`, `50k_plus`, `unsure` | Le visiteur |
| `timeline` | Select : `this_quarter`, `next_quarter`, `later`, `researching` | Le visiteur |
| `message` | Textarea, facultatif | Le visiteur |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | Texte, facultatif | Votre serveur, depuis la première visite |
| `fit` | Select : `qualified`, `nurture` | Votre serveur, après validation |

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

## Un cookie de premier contact signé

`app/lead-source.server.ts`:

```ts
import { createCookie } from "react-router";

export interface LeadSource {
  utm_source: string;
  utm_medium: string;
  utm_campaign: string;
  referrer: string;
  landing_page: string;
}

export const leadSource = createCookie("lead_source", {
  httpOnly: true,
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 30,
  secrets: [process.env.COOKIE_SECRET ?? ""],
});

export async function firstTouch(request: Request): Promise<LeadSource | null> {
  const url = new URL(request.url);
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const tag = (key: string) => url.searchParams.get(key)?.slice(0, 100) ?? "";
  const source: LeadSource = {
    utm_source: tag("utm_source"),
    utm_medium: tag("utm_medium"),
    utm_campaign: tag("utm_campaign"),
    referrer: from && from.host !== url.host ? from.href.slice(0, 300) : "",
    landing_page: url.pathname.slice(0, 200),
  };
  const hasSignal = source.referrer || source.utm_source || source.utm_medium || source.utm_campaign;
  return hasSignal ? source : null;
}
```

`app/root.tsx`:

```tsx
import { leadSource, firstTouch } from "./lead-source.server";
import type { Route } from "./+types/root";

const recordFirstTouch: Route.MiddlewareFunction = async ({ request }, next) => {
  const response = await next();
  if (request.method !== "GET") return response;
  if (await leadSource.parse(request.headers.get("Cookie"))) return response;
  const source = await firstTouch(request);
  if (source) response.headers.append("Set-Cookie", await leadSource.serialize(source));
  return response;
};

export const middleware: Route.MiddlewareFunction[] = [recordFirstTouch];
```

- Le suffixe `.server.ts` garde le module, et `COOKIE_SECRET`, hors du bundle client.
- `leadSource.parse` renvoie `null` pour un cookie absent et pour un cookie dont la signature ne correspond pas : une valeur trafiquée est donc ignorée, pas crue.
- Le middleware appelle d'abord `next()`, puis ajoute seulement un en-tête. La documentation recommande de lire le statut et de définir les en-têtes sur la réponse du serveur, sans toucher à son corps.
- Le middleware serveur ne s'exécute lors des navigations côté client que si elles récupèrent `.data`. Ce n'est pas un problème : les tags arrivent avec la première requête de document.

## L'action de route

`app/routes/contact-sales.tsx`:

```tsx
import { data, Form, redirect, useNavigation } from "react-router";
import { type LeadSource, leadSource } from "../lead-source.server";
import type { Route } from "./+types/contact-sales";

const CHOICES = {
  company_size: ["1_10", "11_50", "51_200", "201_1000", "1000_plus"],
  budget: ["under_10k", "10k_50k", "50k_plus", "unsure"],
  timeline: ["this_quarter", "next_quarter", "later", "researching"],
};

function validate(values: Record<string, string>) {
  const errors: Record<string, string> = {};
  if (!values.name) errors.name = "Tell us who to reply to.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = "Enter a work email we can reply to.";
  if (!values.company) errors.company = "Which company is this for?";
  for (const [key, allowed] of Object.entries(CHOICES)) {
    if (!allowed.includes(values[key])) errors[key] = "Pick one of the options.";
  }
  if (values.message.length > 5000) errors.message = "Keep it under 5,000 characters.";
  return errors;
}

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const keys = ["name", "email", "company", "company_size", "budget", "timeline", "message", "_gotcha"];
  const values = Object.fromEntries(keys.map((key) => [key, String(form.get(key) ?? "").trim()]));
  const errors = validate(values);
  if (Object.keys(errors).length) return data({ errors, values }, { status: 400 });

  const source: Partial<LeadSource> = (await leadSource.parse(request.headers.get("Cookie"))) ?? {};
  const qualified =
    values.company_size !== "1_10" &&
    ["10k_50k", "50k_plus"].includes(values.budget) &&
    ["this_quarter", "next_quarter"].includes(values.timeline);
  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({ ...source, ...values, fit: qualified ? "qualified" : "nurture" }),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const failed = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
    if (!Object.keys(failed).length) failed.form = "We couldn't send that. Email sales@example.com instead.";
    return data({ errors: failed, values }, { status: response.status });
  }

  if (!qualified || values._gotcha) return redirect("/contact-sales/thanks", 303);
  return redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name: values.name, email: values.email })}`, 303);
}

export default function ContactSales({ actionData }: Route.ComponentProps) {
  const pending = useNavigation().state === "submitting";
  const errors: Record<string, string | undefined> = actionData?.errors ?? {};
  const values: Record<string, string | undefined> = actionData?.values ?? {};
  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
      {error("company")}
      <label htmlFor="company_size">Company size</label>
      <select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""}>
        <option value="" disabled>Choose one</option>
        <option value="1_10">1 to 10</option>
        <option value="11_50">11 to 50</option>
        <option value="51_200">51 to 200</option>
        <option value="201_1000">201 to 1,000</option>
        <option value="1000_plus">More than 1,000</option>
      </select>
      {error("company_size")}
      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue={values.budget ?? ""}>
        <option value="" disabled>Choose one</option>
        <option value="under_10k">Under $10k</option>
        <option value="10k_50k">$10k to $50k</option>
        <option value="50k_plus">More than $50k</option>
        <option value="unsure">Not sure yet</option>
      </select>
      {error("budget")}
      <label htmlFor="timeline">When do you want to start?</label>
      <select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""}>
        <option value="" disabled>Choose one</option>
        <option value="this_quarter">This quarter</option>
        <option value="next_quarter">Next quarter</option>
        <option value="later">Later this year</option>
        <option value="researching">Researching for now</option>
      </select>
      {error("timeline")}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />
      {error("message")}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </Form>
  );
}
```

- La validation est une petite fonction sans dépendance. Remplacez-la par Zod si vous l'utilisez déjà ; la forme de l'action reste la même.
- `data({ errors, values }, { status: 400 })` envoie un vrai statut d'erreur, ce qui empêche aussi React Router de revalider les loaders après un envoi échoué.
- L'en-tête `Origin` du navigateur est transmis, car un fetch côté serveur n'en envoie aucun, et c'est grâce à lui qu'Ikiform détecte les tests gratuits depuis `localhost`.
- `redirect()` accepte les URL absolues. La documentation recommande de valider les saisies utilisateur dans les cibles de redirection ; ici, seuls les paramètres d'URL viennent du visiteur, et `URLSearchParams` les encode.
- `values` est étalé après `source`, mais toutes les clés diffèrent : le cookie ne peut donc pas écraser un champ du formulaire.

### Interface d'attente et d'erreur

`useNavigation().state` vaut `submitting` pendant l'exécution de l'action, ce qui désactive le bouton. Les inputs lisent `defaultValue` depuis `actionData.values` : un envoi natif qui revient avec des erreurs affiche donc toujours tout ce que le visiteur a tapé. Avec JavaScript, le formulaire n'est pas réinitialisé du tout.

### Honeypot et vérification humaine

Le honeypot voyage avec les autres valeurs, Ikiform stocke un honeypot rempli comme spam, et l'action l'envoie vers la route de remerciement. La clé secrète contourne la vérification humaine d'Ikiform : vérifiez donc un token Turnstile dans l'action si vous en avez besoin, comme dans [Turnstile sur un formulaire](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md).

## Rendu et déploiement

Les actions ont besoin d'un serveur à l'exécution. Avec `ssr: false` dans `react-router.config.ts`, le build refuse toute route qui a une `action`. Les chemins listés dans `prerender` deviennent des fichiers statiques, et un hébergeur qui sert ces fichiers n'exécute jamais le middleware : laissez donc vos landing pages de campagne rendues côté serveur.

| Hébergeur | À savoir |
| --- | --- |
| Vercel | Ajoutez le preset `@vercel/react-router` dans `react-router.config.ts`. Définissez les deux secrets dans les variables d'environnement du projet. |
| Netlify | Ajoutez `@netlify/vite-plugin-react-router`. Définissez les deux secrets dans les réglages du site. |
| Cloudflare | Utilisez `@cloudflare/vite-plugin` ; React Router 8 a supprimé son propre proxy de développement Cloudflare. Stockez les deux secrets avec `wrangler secret put` et lisez-les comme votre modèle de Worker expose env. |

## Vérifiez que tout fonctionne

Les envois depuis le serveur de développement portent une origine `localhost`. 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).

1. Lancez `react-router dev`, ouvrez `/?utm_source=test&utm_campaign=launch` et repérez le cookie signé `lead_source` dans les outils de développement.
2. Modifiez à la main la valeur du cookie et envoyez un lead. Les champs UTM arrivent vides, car la signature ne correspond plus.
3. Désactivez JavaScript, laissez **Timeline** vide et envoyez. La page se recharge avec l'erreur et vos valeurs.
4. Envoyez comme un lead qualifié : 51 à 200 personnes, plus de 50 k$, le trimestre prochain. Vous arrivez sur Cal.com avec le nom et l'e-mail remplis.
5. Ouvrez **Submissions** dans le tableau de bord et vérifiez `utm_campaign`, `referrer` et `fit`.
6. Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et **Reply** répond à l'expéditeur quand le formulaire a un champ `email`.

## Pièges

| Problème | Que faire |
| --- | --- |
| `middleware` n'a aucun effet | Sous React Router 7, il était derrière le flag `future.v8_middleware`. Activez-le, ou passez à la version 8 où il est activé par défaut. |
| Le build échoue avec `action` functions prohibited | `ssr: false` est défini. Réactivez le SSR pour l'application ; le prérendu de chemins précis fonctionne toujours à côté. |
| Le cookie ne tient jamais | `COOKIE_SECRET` est vide ou change à chaque déploiement, donc chaque signature échoue. Définissez une valeur stable. |
| `422 validation_failed` | L'envoi ne correspond pas à la définition des champs du formulaire. `error.fields` nomme chaque champ et la raison. Faites correspondre le `name` de l'input à l'id du champ. |
| `402 test_limit_reached` | L'espace de travail n'a pas d'offre et a utilisé ses 50 tests gratuits depuis `localhost` ce mois-ci. Choisissez une offre, ou utilisez **Send test** dans le tableau de bord. |

## Questions

### Une action React Router peut-elle rediriger vers un autre domaine ?

Oui. redirect() accepte une URL absolue. Un envoi de formulaire natif suit lui-même l'en-tête Location, et avec JavaScript, React Router fait une navigation complète de document vers l'autre origine.

### Comment poser un cookie depuis un middleware React Router ?

Appelez next() pour obtenir la réponse, puis ajoutez un en-tête Set-Cookie produit par cookie.serialize() de createCookie. Exportez le middleware depuis la route, comme root.tsx, sous forme de Route.MiddlewareFunction[].

### Un formulaire de leads React Router fonctionne-t-il sans JavaScript ?

Oui. Form affiche un élément form normal, l'action s'exécute sur un envoi natif, et renvoyer data() avec des erreurs ou redirect() fonctionne comme avec JavaScript.

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

- [Formulaire de capture de leads](https://ikiform.com/fr/guides/lead-capture.md): Un formulaire de capture de leads sur votre propre site, qui vous écrit avec le lead en adresse de réponse, alerte le chat de l'équipe et alimente votre CRM, pour répondre tant que le lead est encore là.
- [Formulaire de leads TanStack Start](https://ikiform.com/fr/guides/lead-capture/tanstack-start.md): Un formulaire de leads TanStack Start : un middleware de requête global pour les UTM du premier contact, une server function avec Zod et une redirection levée vers votre outil de réservation.
- [Formulaire de leads SvelteKit](https://ikiform.com/fr/guides/lead-capture/sveltekit.md): Un formulaire de leads SvelteKit : le hook handle enregistre les UTM du premier contact, une form action valide avec Valibot et redirect() envoie les leads qualifiés vers un agenda.
- [Formulaire de demande de démo](https://ikiform.com/fr/guides/lead-capture/demo-request.md): Un formulaire de réservation de démo qui stocke le lead, puis envoie le visiteur vers Cal.com ou Calendly avec son nom et son e-mail déjà remplis.
- [Formulaire de demande de devis](https://ikiform.com/fr/guides/lead-capture/quote-request.md): Un formulaire de demande de devis qui reçoit les briefs sous forme de liens partagés plutôt qu'en pièces jointes, fixe une échéance à chaque demande et garde les leads riches en liens hors du spam.
- [Formulaire de demande de consultation](https://ikiform.com/fr/guides/lead-capture/consultation-request.md): Un formulaire de demande de consultation pour comptables, avocats, coachs et cabinets, qui demande comment et quand appeler, et garde les détails sensibles pour l'appel.
