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.
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 première requête de document porte les tags de campagne dans l'URL et le referrer externe dans l'en-tête.
- Le middleware racine laisse la page s'afficher, puis ajoute un Set-Cookie signé avec la source.
- 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.
- Sans JavaScript, le navigateur envoie nativement et suit lui-même le 303.
- 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.
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é.
app/
root.tsx
lead-source.server.ts
routes.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
react-router.config.ts
.envIKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-stringAjoutez 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 |
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Un cookie de premier contact signé
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;
}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.tsgarde le module, etCOOKIE_SECRET, hors du bundle client. leadSource.parserenvoienullpour 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
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
Origindu 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 depuislocalhost. 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, etURLSearchParamsles encode.valuesest étalé aprèssource, 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 |
|---|---|
| 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.
- Lancez
react-router dev, ouvrez/?utm_source=test&utm_campaign=launchet repérez le cookie signélead_sourcedans les outils de développement. - Modifiez à la main la valeur du cookie et envoyez un lead. Les champs UTM arrivent vides, car la signature ne correspond plus.
- Désactivez JavaScript, laissez Timeline vide et envoyez. La page se recharge avec l'erreur et vos valeurs.
- 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.
- Ouvrez Submissions dans le tableau de bord et vérifiez
utm_campaign,referreretfit. - 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
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.
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[].
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.
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.