Formulario de captación de leads8 min de lectura
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.
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 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.
- Cada petición pasa primero por el middleware CSRF; solo comprueba las llamadas a server functions.
- En un GET sin cookie todavía, firstTouch lee las etiquetas UTM y un referrer externo y los guarda.
- La página se renderiza como siempre.
- Al enviar, useServerFn envía el FormData a la server function.
- El validator lo convierte en un objeto plano y el handler le aplica Zod.
- Un lead válido va a Ikiform con la atribución, el encaje y el Origin del visitante.
- El handler lanza una redirección a Cal.com o a la ruta de agradecimiento; useServerFn la sigue. Los errores vuelven como datos.
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 cubre la clave y el endpoint.
src/
start.ts
lib/lead.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
vite.config.ts
.envDefine 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 |
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.Middleware global para el primer contacto
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,OriginoReferercon el origen de la petición. firstTouchse salta todo lo que no sea GET, así que los envíos a server functions nunca tocan la cookie.setCookieygetCookievienen de@tanstack/react-start/servery solo se ejecutan en el servidor.- Una cookie puesta durante una petición no se puede leer con
getCookiehasta la siguiente. Aquí no importa: el lead envía el formulario en una petición posterior.
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()solo cambia la forma delFormData; Zod se ejecuta en el handler consafeParse, 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.fitse 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íaOrigine Ikiform detecta a partir de él las pruebas gratuitas desdelocalhost. redirect({ href })es la forma externa;redirect({ to })se queda dentro de la app. Ambas se lanzan.
La ruta
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.
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.
- Arranca el servidor de desarrollo, abre
/?utm_source=test&utm_campaign=launchy comprueba en las herramientas de desarrollo que la cookielead_sourcees httpOnly. - 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. - 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.
- Envía como un equipo de 1 a 10 personas. Llegas a
/contact-sales/thanks. - Abre Submissions en el panel y comprueba
utm_source,landing_pageyfit. - 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
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.