Formulaire de capture de leads

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.

Preet SutharFondateur, Ikiform

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.

Comment un lead traverse le middleware et la server function de TanStack Startchaque requêteenvoicsrfserverfunctionsseulementfirstTouchGET, poselead_sourcepage SSRou serverfunctionuseServerFnenvoie leFormDatacreateServerFnvalidateur,puis ZodIkiformclé secrète,Originthrowredirecthref : Cal.com{ errors }affichéessous chaquechampnext()next()POSTJSONokZod ou 422Comment un lead traverse le middleware et la server function de TanStack Startchaque requêteenvoicsrfserver functionsseulementfirstTouchGET, poselead_sourcepage SSRou server functionuseServerFnenvoie le FormDatacreateServerFnvalidateur, puis ZodIkiformclé secrète, Originthrow redirecthref : Cal.com{ errors}affichéessous chaquechampnext()next()POSTJSONokZod ou 422
  1. Chaque requête passe d'abord par le middleware CSRF ; il ne vérifie que les appels de server functions.
  2. Sur un GET sans cookie, firstTouch lit les tags UTM et un referrer externe, et les stocke.
  3. La page s'affiche normalement.
  4. À l'envoi, useServerFn envoie le FormData à la server function.
  5. Le validateur le transforme en objet simple et le handler y applique Zod.
  6. Un lead valide part vers Ikiform avec l'attribution, l'adéquation et l'Origin du visiteur.
  7. 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.
Écrire src/start.ts remplace le middleware de requête par défaut de Start : la protection CSRF doit donc être remise à la main.

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.

fichiers
src/
  start.ts
  lib/lead.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
vite.config.ts
.env

Dé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 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 middleware global pour le premier contact

src/start.ts
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, Origin ou Referer à l'origine de la requête.
  • firstTouch ignore tout ce qui n'est pas un GET : les envois vers les server functions ne touchent donc jamais au cookie.
  • setCookie et getCookie viennent de @tanstack/react-start/server et ne s'exécutent que côté serveur.
  • Un cookie posé pendant une requête n'est lisible par getCookie qu'à la suivante. Ce n'est pas un problème ici : le lead envoie lors d'une requête ultérieure.

La server function

src/lib/lead.ts
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 le FormData ; Zod s'exécute dans le handler avec safeParse, 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.
  • fit est 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 depuis localhost.
  • redirect({ href }) est la forme externe ; redirect({ to }) reste dans l'application. Les deux sont levées.

La route

src/routes/contact-sales.tsx
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
VercelAjoutez le plugin nitro/vite et déployez. Définissez IKIFORM_SECRET_KEY dans les variables d'environnement du projet.
NetlifyAjoutez @netlify/vite-plugin-tanstack-start à vite.config.ts. Définissez la clé dans les réglages du site.
CloudflareAjoutez @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.

  1. Lancez le serveur de développement, ouvrez /?utm_source=test&utm_campaign=launch et vérifiez dans les outils de développement que le cookie lead_source est httpOnly.
  2. Sur /contact-sales, choisissez Under $10k et laissez Company vide. L'erreur de l'entreprise s'affiche et toutes les autres valeurs restent.
  3. 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.
  4. Envoyez comme une équipe de 1 à 10 personnes. Vous arrivez sur /contact-sales/thanks.
  5. Ouvrez Submissions dans le tableau de bord et vérifiez utm_source, landing_page 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
403 de votre propre server function après l'ajout de src/start.tsLa 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 functionsrc/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é clientgetCookie a été appelé hors d'une server function ou d'un middleware. Gardez-le dans .handler() ou .server().
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.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.