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

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

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](https://ikiform.com/fr/guides/lead-capture.md#form) 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.

Schéma : Comment un lead traverse le middleware et la server function de TanStack Start

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](https://ikiform.com/fr/docs/guides/server.md) couvre la clé et l'endpoint.

`fichiers`:

```text
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 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 middleware global pour le premier contact

`src/start.ts`:

```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`:

```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`:

```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](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/fr/pricing).

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

### Comment rediriger vers une URL externe depuis une server function TanStack Start ?

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.

### Où placer le middleware dans TanStack Start ?

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

### Comment valider un FormData dans une server function TanStack Start ?

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.

### 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 React Router](https://ikiform.com/fr/guides/lead-capture/react-router.md): 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.
- [Formulaire de leads Next.js](https://ikiform.com/fr/guides/lead-capture/nextjs.md): Un formulaire de leads Next.js App Router qui valide avec Zod dans une server action, garde les UTM du premier contact dans un cookie et envoie les leads qualifiés vers votre 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.
