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

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

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

Diagrama: La misma action de React Router atendiendo un lead con y sin JavaScript

1. La primera petición de documento lleva las etiquetas de campaña en la URL y el referrer externo en la cabecera.
2. El middleware raíz deja que la página se renderice y luego añade un Set-Cookie firmado con la fuente.
3. 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.
4. Sin JavaScript, el navegador envía de forma nativa y sigue el 303 por sí mismo.
5. 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.

Una action, dos transportes. El middleware solo se ejecuta en las peticiones de documento, que es donde ocurre el primer contacto.

## 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](https://ikiform.com/es/docs/guides/server.md) explica cómo crear la clave.

`archivos`:

```text
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-string
```

Despué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 |

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

## Una cookie firmada de primer contacto

`app/lead-source.server.ts`:

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

`app/root.tsx`:

```tsx
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.ts` mantiene el módulo, y `COOKIE_SECRET`, fuera del bundle del cliente.
- `leadSource.parse` devuelve `null` si 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

`app/routes/contact-sales.tsx`:

```tsx
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 `Origin` del navegador, porque un fetch desde el servidor no envía ninguna e Ikiform detecta a partir de ella las pruebas gratuitas desde `localhost`.
- `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, y `URLSearchParams` la codifica.
- `values` se expande después de `source`, 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](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md).

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

1. Ejecuta `react-router dev`, abre `/?utm_source=test&utm_campaign=launch` y busca la cookie firmada `lead_source` en las herramientas de desarrollo.
2. 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.
3. Desactiva JavaScript, deja **Timeline** vacío y envía. La página se recarga con el error y tus valores.
4. 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.
5. Abre **Submissions** en el panel y comprueba `utm_campaign`, `referrer` 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 |
| --- | --- |
| `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

### ¿Puede una action de React Router redirigir a otro dominio?

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.

### ¿Cómo pongo una cookie desde un middleware de React Router?

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[].

### ¿Funciona sin JavaScript un formulario de leads en React Router?

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.

### ¿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 TanStack Start](https://ikiform.com/es/guides/lead-capture/tanstack-start.md): 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.
- [Formulario de leads en SvelteKit](https://ikiform.com/es/guides/lead-capture/sveltekit.md): Un formulario de leads en SvelteKit: el hook handle registra las UTM del primer contacto, una form action valida con Valibot y redirect() manda a los leads cualificados a un 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.
