Formulaire de capture de leads

Formulaire de capture de leads8 min de lecture

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.

Preet SutharFondateur, Ikiform

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

La même action React Router qui sert un lead avec et sans JavaScriptpremière visiteavec JavaScriptsans JavaScriptGET dudocument?utm_campaign=...middlewareracineawait next(), puisSet-Cookiecookie signélead_source<Form>récupère .dataactionvalider, envoyer,déciderredirect()chargement deCal.com<form>POST natifactionmême fonction303 Locationle navigateur suitla page seréafficheerreurs et valeurscreateCookiePOST303POST303data(..., 400)La même action React Router qui sert un lead avec et sans JavaScriptpremière visiteavec JavaScriptsans JavaScriptGET du document?utm_campaign=...middlewareracineawait next(), puisSet-Cookiecookie signélead_source<Form>récupère .dataactionvalider, envoyer,déciderredirect()chargement deCal.com<form>POST natifactionmême fonction303 Locationle navigateur suitla pageseréafficheerreurs etvaleurscreateCookiePOST303POST303data(..., 400)
  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 explique comment créer la clé.

fichiers
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
.env
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 champType dans IkiformRenseigné par
name, email, companyTexte, e-mail, texteLe visiteur
company_sizeSelect : 1_10, 11_50, 51_200, 201_1000, 1000_plusLe visiteur
budgetSelect : under_10k, 10k_50k, 50k_plus, unsureLe visiteur
timelineSelect : this_quarter, next_quarter, later, researchingLe visiteur
messageTextarea, facultatifLe visiteur
utm_source, utm_medium, utm_campaign, referrer, landing_pageTexte, facultatifVotre serveur, depuis la première visite
fitSelect : qualified, nurtureVotre 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 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
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
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
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.

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
VercelAjoutez le preset @vercel/react-router dans react-router.config.ts. Définissez les deux secrets dans les variables d'environnement du projet.
NetlifyAjoutez @netlify/vite-plugin-react-router. Définissez les deux secrets dans les réglages du site.
CloudflareUtilisez @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.

  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èmeQue faire
middleware n'a aucun effetSous 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 prohibitedssr: 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 jamaisCOOKIE_SECRET est vide ou change à chaque déploiement, donc chaque signature échoue. Définissez une valeur stable.
422 validation_failedL'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_reachedL'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 étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.