Formulario de captación de leads8 min de lectura
Crea un formulario de leads con cualificación en React Router
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.
Registra las etiquetas UTM y el referrer con un middleware raíz en la primera petición de documento, en una cookie firmada de createCookie. Valida el lead en el action de la ruta, reenvíalo con la atribución a tu backend de formularios y devuelve redirect() a tu agenda para los leads cualificados o data() con errores de campo para el resto. <Form> envía con o sin JavaScript, así que la misma action sirve para ambos casos.
La pestaña de React Router de la guía principal reenvía un lead desde una action de ruta y muestra una línea de agradecimiento. Esta página construye la versión de ventas en modo framework: tres preguntas de cualificación comprobadas en el servidor, la primera campaña que trajo al visitante y una redirección a tu calendario para los leads que encajan.
Qué cambia con React Router
Las APIs de datos de React Router se diseñaron en torno a los formularios HTML normales. <Form> envía al action de la ruta igual con o sin JavaScript, y lo que devuelva la action, data() con errores o redirect(), funciona en ambos casos. Con JavaScript, React Router sigue una redirección a otro origen con una carga completa del documento. Escribes el camino del servidor una sola vez.
Hay dos piezas más que encajan aquí. El middleware, activado por defecto desde React Router 8, se ejecuta en cada petición de documento, así que la ruta raíz puede registrar el primer contacto. Y createCookie firma su valor con tu secreto, así que un visitante no puede reescribir su propia atribución antes de que llegue a tu CRM.
- La primera petición de documento lleva las etiquetas de campaña en la URL y el referrer externo en la cabecera.
- El middleware raíz deja que la página se renderice y luego añade un Set-Cookie firmado con la fuente.
- Con JavaScript, Form envía a la action mediante una petición de datos y React Router sigue la redirección con una carga completa de la página.
- Sin JavaScript, el navegador envía de forma nativa y sigue el 303 por sí mismo.
- En ambos casos, un fallo de validación devuelve datos con estado 400 y la página muestra los errores junto a los valores escritos.
Configuración del proyecto
Parte de una app en modo framework con React Router 8. Añade dos variables solo de servidor: la clave secreta de Ikiform y un secreto para firmar la cookie. Enviar desde un servidor explica cómo crear la clave.
app/
root.tsx
lead-source.server.ts
routes.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
react-router.config.ts
.envIKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-stringDespués añade los campos en la pestaña Fields del formulario. Ikiform vuelve a hacer las mismas comprobaciones y rechaza las claves que no conoce.
| 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.Una cookie firmada de primer contacto
import { createCookie } from "react-router";
export interface LeadSource {
utm_source: string;
utm_medium: string;
utm_campaign: string;
referrer: string;
landing_page: string;
}
export const leadSource = createCookie("lead_source", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
secrets: [process.env.COOKIE_SECRET ?? ""],
});
export async function firstTouch(request: Request): Promise<LeadSource | null> {
const url = new URL(request.url);
const referer = request.headers.get("referer") ?? "";
const from = URL.canParse(referer) ? new URL(referer) : null;
const tag = (key: string) => url.searchParams.get(key)?.slice(0, 100) ?? "";
const source: LeadSource = {
utm_source: tag("utm_source"),
utm_medium: tag("utm_medium"),
utm_campaign: tag("utm_campaign"),
referrer: from && from.host !== url.host ? from.href.slice(0, 300) : "",
landing_page: url.pathname.slice(0, 200),
};
const hasSignal = source.referrer || source.utm_source || source.utm_medium || source.utm_campaign;
return hasSignal ? source : null;
}import { leadSource, firstTouch } from "./lead-source.server";
import type { Route } from "./+types/root";
const recordFirstTouch: Route.MiddlewareFunction = async ({ request }, next) => {
const response = await next();
if (request.method !== "GET") return response;
if (await leadSource.parse(request.headers.get("Cookie"))) return response;
const source = await firstTouch(request);
if (source) response.headers.append("Set-Cookie", await leadSource.serialize(source));
return response;
};
export const middleware: Route.MiddlewareFunction[] = [recordFirstTouch];- El sufijo
.server.tsmantiene el módulo, yCOOKIE_SECRET, fuera del bundle del cliente. leadSource.parsedevuelvenullsi falta la cookie y si su firma no coincide, así que un valor manipulado se ignora en lugar de darse por bueno.- El middleware llama primero a
next()y luego solo añade una cabecera. La documentación dice que hay que leer el estado y poner cabeceras en la respuesta del servidor, sin tocar su cuerpo. - El middleware de servidor se ejecuta en las navegaciones del lado del cliente solo cuando piden
.data. No importa: las etiquetas llegan en la primera petición de documento.
La action de la ruta
import { data, Form, redirect, useNavigation } from "react-router";
import { type LeadSource, leadSource } from "../lead-source.server";
import type { Route } from "./+types/contact-sales";
const CHOICES = {
company_size: ["1_10", "11_50", "51_200", "201_1000", "1000_plus"],
budget: ["under_10k", "10k_50k", "50k_plus", "unsure"],
timeline: ["this_quarter", "next_quarter", "later", "researching"],
};
function validate(values: Record<string, string>) {
const errors: Record<string, string> = {};
if (!values.name) errors.name = "Tell us who to reply to.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = "Enter a work email we can reply to.";
if (!values.company) errors.company = "Which company is this for?";
for (const [key, allowed] of Object.entries(CHOICES)) {
if (!allowed.includes(values[key])) errors[key] = "Pick one of the options.";
}
if (values.message.length > 5000) errors.message = "Keep it under 5,000 characters.";
return errors;
}
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const keys = ["name", "email", "company", "company_size", "budget", "timeline", "message", "_gotcha"];
const values = Object.fromEntries(keys.map((key) => [key, String(form.get(key) ?? "").trim()]));
const errors = validate(values);
if (Object.keys(errors).length) return data({ errors, values }, { status: 400 });
const source: Partial<LeadSource> = (await leadSource.parse(request.headers.get("Cookie"))) ?? {};
const qualified =
values.company_size !== "1_10" &&
["10k_50k", "50k_plus"].includes(values.budget) &&
["this_quarter", "next_quarter"].includes(values.timeline);
const origin = request.headers.get("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({ ...source, ...values, fit: qualified ? "qualified" : "nurture" }),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
const fields: Record<string, string[]> = result?.error?.fields ?? {};
const failed = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
if (!Object.keys(failed).length) failed.form = "We couldn't send that. Email sales@example.com instead.";
return data({ errors: failed, values }, { status: response.status });
}
if (!qualified || values._gotcha) return redirect("/contact-sales/thanks", 303);
return redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name: values.name, email: values.email })}`, 303);
}
export default function ContactSales({ actionData }: Route.ComponentProps) {
const pending = useNavigation().state === "submitting";
const errors: Record<string, string | undefined> = actionData?.errors ?? {};
const values: Record<string, string | undefined> = actionData?.values ?? {};
const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
{error("name")}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
{error("email")}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
{error("company")}
<label htmlFor="company_size">Company size</label>
<select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""}>
<option value="" disabled>Choose one</option>
<option value="1_10">1 to 10</option>
<option value="11_50">11 to 50</option>
<option value="51_200">51 to 200</option>
<option value="201_1000">201 to 1,000</option>
<option value="1000_plus">More than 1,000</option>
</select>
{error("company_size")}
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue={values.budget ?? ""}>
<option value="" disabled>Choose one</option>
<option value="under_10k">Under $10k</option>
<option value="10k_50k">$10k to $50k</option>
<option value="50k_plus">More than $50k</option>
<option value="unsure">Not sure yet</option>
</select>
{error("budget")}
<label htmlFor="timeline">When do you want to start?</label>
<select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""}>
<option value="" disabled>Choose one</option>
<option value="this_quarter">This quarter</option>
<option value="next_quarter">Next quarter</option>
<option value="later">Later this year</option>
<option value="researching">Researching for now</option>
</select>
{error("timeline")}
<label htmlFor="message">Anything we should know?</label>
<textarea id="message" name="message" rows={4} defaultValue={values.message} />
{error("message")}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error("form")}
<button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</Form>
);
}- La validación es una pequeña función sin dependencias. Cámbiala por Zod si ya lo usas; la forma de la action no cambia.
data({ errors, values }, { status: 400 })envía un estado de error real, lo que además evita que React Router revalide los loaders tras un envío fallido.- Se reenvía la cabecera
Origindel navegador, porque un fetch desde el servidor no envía ninguna e Ikiform detecta a partir de ella las pruebas gratuitas desdelocalhost. redirect()acepta URLs absolutas. La documentación advierte que hay que validar la entrada del usuario en los destinos de redirección; aquí solo la query string viene del visitante, yURLSearchParamsla codifica.valuesse expande después desource, pero todas las claves son distintas, así que la cookie no puede sobrescribir un campo del formulario.
Estados de envío y de error
useNavigation().state es submitting mientras se ejecuta la action, lo que desactiva el botón. Los inputs leen defaultValue de actionData.values, así que un envío nativo que vuelve con errores sigue mostrando todo lo que escribió el visitante. Con JavaScript, el formulario ni siquiera se reinicia.
Honeypot y verificación humana
El honeypot viaja con los demás valores, Ikiform guarda como spam uno relleno, y la action lo manda a la ruta de agradecimiento. La clave secreta se salta la verificación humana de Ikiform, así que verifica un token de Turnstile en la action si lo necesitas, como en Turnstile en un formulario.
Renderizado y despliegue
Las actions necesitan un servidor en tiempo de ejecución. Con ssr: false en react-router.config.ts, el build rechaza cualquier ruta con un action. Las rutas listadas en prerender se convierten en archivos estáticos, y un hosting que sirve esos archivos nunca ejecuta el middleware, así que deja tus landing pages de campaña renderizadas en el servidor.
| Host | Qué debes saber |
|---|---|
| Vercel | Añade el preset @vercel/react-router en react-router.config.ts. Define ambos secretos en las variables de entorno del proyecto. |
| Netlify | Añade @netlify/vite-plugin-react-router. Define ambos secretos en los ajustes del sitio. |
| Cloudflare | Usa @cloudflare/vite-plugin; React Router 8 eliminó su propio proxy de desarrollo para Cloudflare. Guarda ambos secretos con wrangler secret put y léelos como exponga env tu plantilla de Worker. |
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.
- Ejecuta
react-router dev, abre/?utm_source=test&utm_campaign=launchy busca la cookie firmadalead_sourceen las herramientas de desarrollo. - Edita a mano el valor de la cookie y envía un lead. Los campos UTM llegan vacíos, porque la firma ya no coincide.
- Desactiva JavaScript, deja Timeline vacío y envía. La página se recarga con el error y tus valores.
- Envía como lead cualificado: de 51 a 200 personas, más de 50k $, el próximo trimestre. Llegas a Cal.com con el nombre y el email rellenos.
- Abre Submissions en el panel y comprueba
utm_campaign,referreryfit. - 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 |
|---|---|
middleware no tiene efecto | En React Router 7 estaba detrás del flag future.v8_middleware. Actívalo, o actualiza a la 8, donde viene por defecto. |
El build falla con funciones action prohibidas | Está puesto ssr: false. Vuelve a activar el SSR en la app; prerenderizar rutas concretas sigue funcionando a la vez. |
| La cookie nunca se queda | COOKIE_SECRET está vacío o cambia en cada despliegue, así que todas las firmas fallan. Define un valor estable. |
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. |
402 test_limit_reached | El espacio de trabajo no tiene plan y ya usó sus 50 pruebas gratuitas desde localhost este mes. Elige un plan o usa Send test en el panel. |
Preguntas
Sí. redirect() acepta una URL absoluta. Un envío de formulario nativo sigue la cabecera Location por sí mismo, y con JavaScript React Router hace una navegación de documento completa al otro origen.
Llama a next() para obtener la respuesta y luego añade una cabecera Set-Cookie creada con cookie.serialize() de createCookie. Exporta el middleware desde la ruta, como root.tsx, como Route.MiddlewareFunction[].
Sí. Form renderiza un elemento form normal, la action se ejecuta con un envío nativo, y devolver data() con errores o redirect() funciona igual que con JavaScript.
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.