Formulaire de capture de leads8 min de lecture
Créer un formulaire de leads qualifiant avec TanStack Start
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.
Enregistrez les tags UTM et le referrer à la première requête de page avec un middleware de requête global dans src/start.ts, et gardez-les dans un cookie httpOnly. Validez le lead dans un handler createServerFn avec Zod, transmettez-le avec l'attribution à votre backend de formulaires, et levez redirect({ href }) pour envoyer les leads qualifiés vers votre outil de réservation. Appelez la server function via useServerFn pour que la redirection soit suivie pour vous.
L'onglet TanStack Start du guide principal envoie un lead via une server function et affiche une ligne de remerciement. Cette page construit la version commerciale : trois questions de qualification validées côté serveur, la campagne qui a amené le visiteur, et une redirection directe vers votre agenda quand le lead correspond.
Ce que change TanStack Start
Start n'a pas de convention de middleware par fichier. Tout ce qui doit s'exécuter à chaque requête va dans src/start.ts comme middleware de requête global, qui s'exécute avant le SSR, les server routes et les server functions. C'est là qu'est enregistré le premier contact.
Le piège : Start n'installe automatiquement la protection CSRF des server functions que si src/start.ts n'existe pas. Dès que vous créez ce fichier pour l'attribution, cette protection vous revient et vous devez ajouter vous-même createCsrfMiddleware(). Oubliez-la, et votre endpoint de leads accepte les envois cross-site.
- Chaque requête passe d'abord par le middleware CSRF ; il ne vérifie que les appels de server functions.
- Sur un GET sans cookie, firstTouch lit les tags UTM et un referrer externe, et les stocke.
- La page s'affiche normalement.
- À l'envoi, useServerFn envoie le FormData à la server function.
- Le validateur le transforme en objet simple et le handler y applique Zod.
- Un lead valide part vers Ikiform avec l'attribution, l'adéquation et l'Origin du visiteur.
- Le handler lève une redirection vers Cal.com ou la route de remerciement ; useServerFn la suit. Les erreurs reviennent sous forme de données.
Mise en place du projet
Partez d'un projet TanStack Start sous React et installez zod. Mettez la clé secrète dans .env sous le nom IKIFORM_SECRET_KEY ; les server functions la lisent depuis process.env, et elle n'atteint jamais le bundle client. La documentation n'a pas de page dédiée à Start ; l'envoi depuis un serveur couvre la clé et l'endpoint.
src/
start.ts
lib/lead.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
vite.config.ts
.envDéfinissez les champs dans l'onglet Fields du formulaire, pour qu'Ikiform rejette tout ce qui ne correspond pas, y compris des clés d'attribution que vous n'aviez pas prévues.
| 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 middleware global pour le premier contact
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];
const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });
const firstTouch = createMiddleware().server(({ request, next }) => {
if (request.method !== "GET" || getCookie("lead_source")) return next();
const url = new URL(request.url);
const referer = request.headers.get("referer") ?? "";
const from = URL.canParse(referer) ? new URL(referer) : null;
const external = from && from.host !== url.host ? from.href.slice(0, 300) : "";
const source = new URLSearchParams();
for (const tag of TAGS) source.set(tag, url.searchParams.get(tag)?.slice(0, 100) ?? "");
if (external || TAGS.some((tag) => source.get(tag))) {
source.set("referrer", external);
source.set("landing_page", url.pathname.slice(0, 200));
setCookie("lead_source", source.toString(), {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
}
return next();
});
export const startInstance = createStart(() => ({
requestMiddleware: [csrf, firstTouch],
}));- Le filtre garde la vérification CSRF sur les server functions, comme la documentation de Start le recommande pour un usage global. Il compare
Sec-Fetch-Site,OriginouRefererà l'origine de la requête. firstTouchignore tout ce qui n'est pas un GET : les envois vers les server functions ne touchent donc jamais au cookie.setCookieetgetCookieviennent de@tanstack/react-start/serveret ne s'exécutent que côté serveur.- Un cookie posé pendant une requête n'est lisible par
getCookiequ'à la suivante. Ce n'est pas un problème ici : le lead envoie lors d'une requête ultérieure.
La server function
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, getRequestHeader } from "@tanstack/react-start/server";
import { z } from "zod";
const LeadSchema = z.object({
name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().min(1, "Which company is this for?").max(200),
company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
message: z.string().trim().max(5000).optional(),
_gotcha: z.string().optional(),
});
const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
const FIT = { size: ["1_10"], budget: ["10k_50k", "50k_plus"], timeline: ["this_quarter", "next_quarter"] };
export type LeadResult = { errors: Record<string, string> };
export const submitLead = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return Object.fromEntries([...data].map(([key, value]) => [key, String(value)]));
})
.handler(async ({ data }): Promise<LeadResult> => {
const parsed = LeadSchema.safeParse(data);
if (!parsed.success) {
const { fieldErrors } = z.flattenError(parsed.error);
return { errors: Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""])) };
}
const lead = parsed.data;
const stored = new URLSearchParams(getCookie("lead_source") ?? "");
const qualified =
!FIT.size.includes(lead.company_size) && FIT.budget.includes(lead.budget) && FIT.timeline.includes(lead.timeline);
const origin = getRequestHeader("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({
...lead,
...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
fit: qualified ? "qualified" : "nurture",
}),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
return { errors: Object.keys(errors).length ? errors : { form: result?.error?.message ?? "Try again." } };
}
if (!qualified || lead._gotcha) throw redirect({ to: "/contact-sales/thanks" });
throw redirect({ href: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` });
});.validator()ne fait que remodeler leFormData; Zod s'exécute dans le handler avecsafeParse, pour que les erreurs de champ reviennent sous forme de données plutôt que d'une exception que le composant ne pourrait pas lire champ par champ.fitest décidé côté serveur. Modifier le formulaire ne peut pas promouvoir un lead.getRequestHeader("origin")est transmis, car un fetch côté serveur n'envoie pas d'Origin, et c'est grâce à lui qu'Ikiform détecte les tests gratuits depuislocalhost.redirect({ href })est la forme externe ;redirect({ to })reste dans l'application. Les deux sont levées.
La route
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { submitLead } from "../lib/lead";
export const Route = createFileRoute("/contact-sales")({ component: ContactSales });
const SELECTS = {
company_size: ["Company size", [["1_10", "1 to 10"], ["11_50", "11 to 50"], ["51_200", "51 to 200"], ["201_1000", "201 to 1,000"], ["1000_plus", "More than 1,000"]]],
budget: ["Budget", [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]]],
timeline: ["When do you want to start?", [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]]],
} as const;
function ContactSales() {
const send = useServerFn(submitLead);
const [errors, setErrors] = useState<Record<string, string>>({});
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
const result = await send({ data: new FormData(event.currentTarget) });
setErrors(result?.errors ?? {});
} catch {
setErrors({ form: "We couldn't send that. Email sales@example.com instead." });
} finally {
setPending(false);
}
}
const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);
return (
<form method="post" action={submitLead.url} encType="multipart/form-data" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{error("name")}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{error("email")}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required />
{error("company")}
{Object.entries(SELECTS).map(([id, [label, options]]) => (
<div key={id}>
<label htmlFor={id}>{label}</label>
<select id={id} name={id} required defaultValue="">
<option value="" disabled>Choose one</option>
{options.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
{error(id)}
</div>
))}
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error("form")}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
);
}Le handler d'envoi bloque l'envoi par défaut et appelle la server function via useServerFn, qui est ce qui suit une redirection levée, y compris vers un autre site. Comme le formulaire n'est jamais réinitialisé, chaque valeur reste en place pendant que le visiteur corrige un champ.
L'attribut action={submitLead.url} est le chemin sans JavaScript. Start documente .url pour les formulaires HTML, mais le navigateur affiche alors la réponse brute de la server function au lieu de cette page. Testez-le avec JavaScript désactivé et décidez si c'est acceptable, ou placez un simple lien e-mail vers l'équipe commerciale sous le formulaire.
Honeypot et vérification humaine
Un honeypot rempli est quand même envoyé : Ikiform le stocke donc comme spam, et le handler l'envoie vers la route de remerciement plutôt que vers votre agenda. La clé secrète contourne la vérification humaine d'Ikiform ; ajoutez une vérification Turnstile au handler si des bots trouvent le formulaire, en suivant Turnstile sur un formulaire.
Rendu et déploiement
Les routes sont rendues côté serveur par défaut, et le middleware s'exécute à chacune de ces requêtes. Si vous prérendez les pages marketing en fichiers statiques, le CDN les sert sans exécuter le middleware : l'attribution doit donc commencer sur une page rendue à chaque requête.
| Hébergeur | À savoir |
|---|---|
| Vercel | Ajoutez le plugin nitro/vite et déployez. Définissez IKIFORM_SECRET_KEY dans les variables d'environnement du projet. |
| Netlify | Ajoutez @netlify/vite-plugin-tanstack-start à vite.config.ts. Définissez la clé dans les réglages du site. |
| Cloudflare | Ajoutez @cloudflare/vite-plugin et déployez avec Wrangler. Stockez la clé avec wrangler secret put et activez nodejs_compat pour que process.env la contienne. |
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 le serveur de développement, ouvrez
/?utm_source=test&utm_campaign=launchet vérifiez dans les outils de développement que le cookielead_sourceest httpOnly. - Sur
/contact-sales, choisissez Under $10k et laissez Company vide. L'erreur de l'entreprise s'affiche et toutes les autres valeurs restent. - Envoyez comme un lead qualifié : 201 à 1 000 personnes, 10 k$ à 50 k$, ce trimestre. Le navigateur part vers Cal.com avec le nom et l'e-mail remplis.
- Envoyez comme une équipe de 1 à 10 personnes. Vous arrivez sur
/contact-sales/thanks. - Ouvrez Submissions dans le tableau de bord et vérifiez
utm_source,landing_pageetfit. - 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 |
|---|---|
403 de votre propre server function après l'ajout de src/start.ts | La vérification CSRF a rejeté l'appel. Les requêtes depuis une autre origine publique, par exemple derrière un proxy, ont besoin de createCsrfMiddleware({ origin }). |
| Des envois cross-site atteignent la server function | src/start.ts existe sans createCsrfMiddleware(). Ajoutez-le, comme dans l'exemple. |
| La page ne bouge pas après un envoi qualifié | La server function a été appelée directement, pas via useServerFn : rien ne suit donc la redirection levée. |
| Une erreur sur l'import de code serveur côté client | getCookie a été appelé hors d'une server function ou d'un middleware. Gardez-le dans .handler() ou .server(). |
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. |
Questions
Levez redirect({ href: 'https://...' }) de @tanstack/react-router dans le handler, et appelez la server function via useServerFn dans le composant. useServerFn suit la redirection, y compris vers un autre site.
Le middleware de requête global va dans src/start.ts, renvoyé par createStart sous requestMiddleware. Il s'exécute avant le SSR, les server routes et les server functions. Créer ce fichier implique d'ajouter vous-même createCsrfMiddleware().
Utilisez .validator() pour vérifier que l'input est un FormData et le transformer en objet simple, puis exécutez le safeParse de Zod dans le handler pour pouvoir renvoyer un message par champ.
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.