Formulario de captación de leads

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.

Preet SutharFundador, Ikiform

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 misma action de React Router atendiendo un lead con y sin JavaScriptprimera visitacon JavaScriptsin JavaScriptGET dedocumento?utm_campaign=...middlewareraízawait next() yluego Set-Cookiecookie firmadalead_source<Form>pide .dataactionvalida, envía,decideredirect()carga de documentoa Cal.com<form>POST nativoactionmisma función303 Locationel navegador lasiguela página sevuelve arenderizarerrores y valorescreateCookiePOST303POST303data(..., 400)La misma action de React Router atendiendo un lead con y sin JavaScriptprimera visitacon JavaScriptsin JavaScriptGET dedocumento?utm_campaign=...middleware raízawait next() y luegoSet-Cookiecookie firmadalead_source<Form>pide .dataactionvalida, envía,decideredirect()carga de documento aCal.com<form>POST nativoactionmisma función303 Locationel navegador lasiguelapáginasevuelve arenderizarerrores yvalorescreateCookiePOST303POST303data(..., 400)
  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 explica cómo crear la clave.

archivos
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
.env
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 campoTipo en IkiformLo pone
name, email, companyTexto, email, textoEl visitante
company_sizeSelección: 1_10, 11_50, 51_200, 201_1000, 1000_plusEl visitante
budgetSelección: under_10k, 10k_50k, 50k_plus, unsureEl visitante
timelineSelección: this_quarter, next_quarter, later, researchingEl visitante
messageTextarea, opcionalEl visitante
utm_source, utm_medium, utm_campaign, referrer, landing_pageTexto, opcionalTu servidor, desde la primera visita
fitSelección: qualified, nurtureTu 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 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
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
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
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.

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.

HostQué debes saber
VercelAñade el preset @vercel/react-router en react-router.config.ts. Define ambos secretos en las variables de entorno del proyecto.
NetlifyAñade @netlify/vite-plugin-react-router. Define ambos secretos en los ajustes del sitio.
CloudflareUsa @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.

  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

ProblemaQué hacer
middleware no tiene efectoEn 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 prohibidasEstá puesto ssr: false. Vuelve a activar el SSR en la app; prerenderizar rutas concretas sigue funcionando a la vez.
La cookie nunca se quedaCOOKIE_SECRET está vacío o cambia en cada despliegue, así que todas las firmas fallan. Define un valor estable.
422 validation_failedEl 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_reachedEl 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.