# Crea un formulario de leads con cualificación en TanStack Start

> Un formulario de leads en TanStack Start: middleware global de peticiones para las UTM del primer contacto, una server function con Zod y un redirect lanzado hacia tu agenda.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/lead-capture/tanstack-start

Registra las etiquetas UTM y el referrer en la primera petición de página con un middleware global de peticiones en `src/start.ts`, y guárdalos en una cookie httpOnly. Valida el lead en un handler de `createServerFn` con Zod, reenvíalo con la atribución a tu backend de formularios y lanza `redirect({ href })` para mandar a los leads cualificados a tu agenda. Llama a la server function mediante `useServerFn` para que la redirección se siga automáticamente.

La [pestaña de TanStack Start de la guía principal](https://ikiform.com/es/guides/lead-capture.md#form) envía un lead mediante una server function y muestra una línea de agradecimiento. Esta página construye la versión de ventas: tres preguntas de cualificación validadas en el servidor, la campaña que trajo al visitante y una redirección directa a tu calendario cuando el lead encaja.

## Qué cambia con TanStack Start

Start no tiene una convención de middleware basada en archivos. Todo lo que deba ejecutarse en cada petición va en `src/start.ts` como middleware global de peticiones, que se ejecuta antes del SSR, de las server routes y de las server functions por igual. Ahí es donde se registra el primer contacto.

El problema: Start instala automáticamente la protección CSRF para las server functions solo cuando `src/start.ts` no existe. En cuanto creas el archivo para la atribución, esa protección pasa a ser cosa tuya y debes añadir `createCsrfMiddleware()` tú mismo. Si lo olvidas, tu endpoint de leads acepta envíos desde otros sitios.

Diagrama: Cómo avanza un lead por el middleware y la server function de TanStack Start

1. Cada petición pasa primero por el middleware CSRF; solo comprueba las llamadas a server functions.
2. En un GET sin cookie todavía, firstTouch lee las etiquetas UTM y un referrer externo y los guarda.
3. La página se renderiza como siempre.
4. Al enviar, useServerFn envía el FormData a la server function.
5. El validator lo convierte en un objeto plano y el handler le aplica Zod.
6. Un lead válido va a Ikiform con la atribución, el encaje y el Origin del visitante.
7. El handler lanza una redirección a Cal.com o a la ruta de agradecimiento; useServerFn la sigue. Los errores vuelven como datos.

Escribir src/start.ts sustituye el middleware de peticiones por defecto de Start, así que el CSRF hay que volver a ponerlo a mano.

## Configuración del proyecto

Parte de un proyecto de TanStack Start con React e instala `zod`. Pon la clave secreta en `.env` como `IKIFORM_SECRET_KEY`; las server functions la leen de `process.env` y nunca llega al bundle del cliente. No hay una página de Start en la documentación; [enviar desde un servidor](https://ikiform.com/es/docs/guides/server.md) cubre la clave y el endpoint.

`archivos`:

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

Define los campos en la pestaña **Fields** del formulario, para que Ikiform rechace todo lo que no encaje, incluidas claves de atribución que no tenías previstas.

| Id del campo | Tipo en Ikiform | Lo pone |
| --- | --- | --- |
| `name`, `email`, `company` | Texto, email, texto | El visitante |
| `company_size` | Selección: `1_10`, `11_50`, `51_200`, `201_1000`, `1000_plus` | El visitante |
| `budget` | Selección: `under_10k`, `10k_50k`, `50k_plus`, `unsure` | El visitante |
| `timeline` | Selección: `this_quarter`, `next_quarter`, `later`, `researching` | El visitante |
| `message` | Textarea, opcional | El visitante |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | Texto, opcional | Tu servidor, desde la primera visita |
| `fit` | Selección: `qualified`, `nurture` | Tu servidor, tras la validación |

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `IKIFORM_SECRET_KEY` en variables de entorno solo de servidor.

## Middleware global para el primer contacto

`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],
}));
```

- El filtro mantiene la comprobación CSRF en las server functions, como recomienda la documentación de Start para el uso global. Compara `Sec-Fetch-Site`, `Origin` o `Referer` con el origen de la petición.
- `firstTouch` se salta todo lo que no sea GET, así que los envíos a server functions nunca tocan la cookie.
- `setCookie` y `getCookie` vienen de `@tanstack/react-start/server` y solo se ejecutan en el servidor.
- Una cookie puesta durante una petición no se puede leer con `getCookie` hasta la siguiente. Aquí no importa: el lead envía el formulario en una petición posterior.

## 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()` solo cambia la forma del `FormData`; Zod se ejecuta en el handler con `safeParse`, así que los errores de campo vuelven como datos en lugar de como una excepción que el componente no puede leer campo a campo.
- `fit` se decide en el servidor. Editar el formulario no puede subir de categoría a un lead.
- Se reenvía `getRequestHeader("origin")`, porque un fetch desde el servidor no envía `Origin` e Ikiform detecta a partir de él las pruebas gratuitas desde `localhost`.
- `redirect({ href })` es la forma externa; `redirect({ to })` se queda dentro de la app. Ambas se lanzan.

## La ruta

`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>
  );
}
```

El handler de envío evita el post por defecto y llama a la server function mediante `useServerFn`, que es lo que sigue una redirección lanzada, incluso a otro sitio. Como el formulario nunca se reinicia, todos los valores se quedan en su sitio mientras el visitante corrige un campo.

El atributo `action={submitLead.url}` es el camino sin JavaScript. Start documenta `.url` para formularios HTML, pero entonces el navegador muestra la respuesta en bruto de la server function en lugar de esta página. Pruébalo con JavaScript desactivado y decide si es aceptable, o pon un simple enlace de email a ventas debajo del formulario.

### Honeypot y verificación humana

Un honeypot relleno se envía igualmente, así que Ikiform lo guarda como spam, y el handler lo manda a la ruta de agradecimiento en lugar de a tu calendario. La clave secreta se salta la verificación humana de Ikiform; añade una comprobación de Turnstile al handler si los bots encuentran el formulario, siguiendo [Turnstile en un formulario](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md).

## Renderizado y despliegue

Las rutas se renderizan en el servidor por defecto, y el middleware se ejecuta en cada una de esas peticiones. Si prerenderizas las páginas de marketing como archivos estáticos, la CDN las sirve sin ejecutar el middleware, así que la atribución debe empezar en una página que se renderice en cada petición.

| Host | Qué debes saber |
| --- | --- |
| Vercel | Añade el plugin `nitro/vite` y despliega. Define `IKIFORM_SECRET_KEY` en las variables de entorno del proyecto. |
| Netlify | Añade `@netlify/vite-plugin-tanstack-start` a `vite.config.ts`. Define la clave en los ajustes del sitio. |
| Cloudflare | Añade `@cloudflare/vite-plugin` y despliega con Wrangler. Guarda la clave con `wrangler secret put` y activa `nodejs_compat` para que `process.env` la contenga. |

## Comprueba que funciona

Los envíos desde el servidor de desarrollo llevan un origen `localhost`. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde `localhost`. Para salir a producción, elige Pro desde 6 $/mes para 500 envíos, o paga una vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los [precios](https://ikiform.com/es/pricing).

1. Arranca el servidor de desarrollo, abre `/?utm_source=test&utm_campaign=launch` y comprueba en las herramientas de desarrollo que la cookie `lead_source` es httpOnly.
2. En `/contact-sales`, elige **Under $10k** y deja **Company** vacío. Aparece el error de la empresa y todos los demás valores se mantienen.
3. Envía como lead cualificado: de 201 a 1.000 personas, de 10k $ a 50k $, este trimestre. El navegador se va a Cal.com con el nombre y el email rellenos.
4. Envía como un equipo de 1 a 10 personas. Llegas a `/contact-sales/thanks`.
5. Abre **Submissions** en el panel y comprueba `utm_source`, `landing_page` y `fit`.
6. Revisa la bandeja de entrada de cada destinatario de las notificaciones. Cada uno recibe un email con los campos, y **Reply** va a quien envió el formulario cuando este tiene un campo `email`.

## Errores comunes

| Problema | Qué hacer |
| --- | --- |
| `403` de tu propia server function después de añadir `src/start.ts` | La comprobación CSRF rechazó la llamada. Las peticiones desde otro origen público, por ejemplo detrás de un proxy, necesitan `createCsrfMiddleware({ origin })`. |
| Los envíos desde otros sitios llegan a la server function | `src/start.ts` existe sin `createCsrfMiddleware()`. Añádelo, como en el ejemplo. |
| La página no cambia tras un envío cualificado | Se llamó a la server function directamente, no mediante `useServerFn`, así que nada sigue la redirección lanzada. |
| Un error sobre importar código de servidor en el cliente | Se llamó a `getCookie` fuera de una server function o de un middleware. Mantenlo dentro de `.handler()` o `.server()`. |
| `422 validation_failed` | El envío no coincide con la definición de campos del formulario. `error.fields` indica cada campo y el motivo. Haz que el `name` del input coincida con el id del campo. |

## Preguntas

### ¿Cómo redirijo a una URL externa desde una server function de TanStack Start?

Lanza redirect({ href: 'https://...' }) de @tanstack/react-router dentro del handler y llama a la server function mediante useServerFn en el componente. useServerFn sigue la redirección, también a otro sitio.

### ¿Dónde va el middleware en TanStack Start?

El middleware global de peticiones va en src/start.ts, devuelto desde createStart como requestMiddleware. Se ejecuta antes del SSR, las server routes y las server functions. Crear ese archivo implica añadir createCsrfMiddleware() tú mismo.

### ¿Cómo valido FormData en una server function de TanStack Start?

Usa .validator() para comprobar que la entrada es FormData y convertirla en un objeto plano, y luego ejecuta safeParse de Zod en el handler para poder devolver un mensaje por campo.

### ¿Ikiform es gratis?

No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.

## Más guías

- [Formulario de captación de leads](https://ikiform.com/es/guides/lead-capture.md): Un formulario de captación de leads en tu propio sitio que te avisa por email con reply-to, avisa al chat del equipo y alimenta tu CRM, para responder mientras el lead sigue ahí.
- [Formulario de leads en React Router](https://ikiform.com/es/guides/lead-capture/react-router.md): Un formulario de leads en React Router en modo framework: middleware raíz con una cookie firmada de primer contacto, una action de ruta que valida y una redirección a tu agenda.
- [Formulario de leads en Next.js](https://ikiform.com/es/guides/lead-capture/nextjs.md): Un formulario de leads en el App Router de Next.js que valida con Zod en una server action, guarda los UTMs del primer contacto en una cookie y envía los leads cualificados a tu calendario.
- [Formulario de solicitud de demo](https://ikiform.com/es/guides/lead-capture/demo-request.md): Un formulario para reservar una demo que guarda el lead y luego envía al visitante a Cal.com o Calendly con su nombre y email ya rellenados.
- [Formulario de solicitud de presupuesto](https://ikiform.com/es/guides/lead-capture/quote-request.md): Un formulario de solicitud de presupuesto que recoge los briefs como enlaces compartidos en lugar de archivos, pone fecha límite a cada solicitud y evita que los leads con muchos enlaces acaben en spam.
- [Formulario de solicitud de consulta](https://ikiform.com/es/guides/lead-capture/consultation-request.md): Un formulario de solicitud de consulta para gestorías, abogados, coaches y clínicas que pregunta cómo y cuándo llamar, y deja los detalles sensibles para la llamada.
