Formulario de captación de leads

Formulario de captación de leads8 min de lectura

Crea un formulario de leads que cualifique en Astro

Un formulario de leads en Astro basado en Actions: validación con Zod, UTMs del primer contacto en un sitio estático, una página de contacto bajo demanda y una redirección a tu agenda.

Preet SutharFundador, Ikiform

Mantén prerenderizadas tus páginas de marketing y registra las etiquetas UTM y el referrer de la primera visita en una pequeña cookie desde un script de cliente, porque el middleware de una página prerenderizada solo se ejecuta al hacer el build. Renderiza la página de contacto bajo demanda, valida el lead en una Astro Action con astro/zod y reenvíalo con la atribución a tu backend de formularios. Devuelve la siguiente URL desde la action y llama a Astro.redirect() para mandar los leads cualificados a tu agenda.

La pestaña de Astro de la guía principal envía un lead mediante una action y muestra una línea de agradecimiento. Esta página lo convierte en un formulario de ventas: tamaño de la empresa, presupuesto y plazos comprobados por Zod en tu servidor, datos de campaña que sobreviven en un sitio estático, y un paso a tu calendario para los leads que encajan.

Qué cambia con Astro

Astro prerenderiza las páginas por defecto, y eso condiciona todo lo relativo a la atribución. El middleware se ejecuta una vez para una página prerenderizada, al hacer el build, sin visitante, sin cookies y sin query string. La landing page en la que de verdad aparece ?utm_source= casi siempre está prerenderizada, así que el middleware nunca ve las etiquetas.

Así que el primer contacto se registra en el navegador, con un script corto en tu layout, y la action lo lee de vuelta en el servidor. La página de contacto es la única que debe renderizarse bajo demanda, porque la documentación de Astro lo exige para cualquier página que llame a una action desde un formulario.

Dónde ejecuta Astro cada parte de un formulario de leadsatribuciónenvíomiddlewarese ejecuta unavez, sinvisitantescriptFirstTouchlee ?utm_ y elreferrercookielead_sourcese envía con elpostPOST delformularioaction={actions.submitLead}Astro ActionZod, lee lacookieAstro.redirectCal.com o/thanksmisma páginamensaje bajocada campoguardaform dataresult.data.nextisInputErrordurante el builden el navegadorbajo demandaDónde ejecuta Astro cada parte de un formulario de leadsatribuciónenvíomiddlewarese ejecuta una vez,sin visitantescriptFirstTouchlee ?utm_ y elreferrercookielead_sourcese envía con el postPOST delformularioaction={actions.submitLead}Astro ActionZod, lee la cookieAstro.redirectCal.com o /thanksmismapáginamensajebajo cadacampoguardaform dataresult.data.nextisInputError
  1. Durante el build, el middleware se ejecuta para las páginas prerenderizadas sin datos de petición, así que no puede registrar una visita.
  2. En el navegador, el script FirstTouch lee las etiquetas UTM y el referrer en la primera vista de página.
  3. Los guarda una vez en una cookie lead_source que llevan todas las peticiones posteriores.
  4. El formulario de contacto envía a la action con un formulario HTML normal, sin necesidad de JavaScript.
  5. La action valida con Zod, lee la cookie y reenvía el lead a Ikiform.
  6. La página lee el resultado y redirige a la agenda o a la página de agradecimiento; los errores de entrada se muestran bajo sus campos.
Solo la página de contacto necesita un servidor. Todo lo que el visitante lee antes sigue siendo estático.

Configuración del proyecto

Añade un adaptador con npx astro add vercel (o netlify, o cloudflare) y declara la clave con astro:env, para que Astro se niegue a hacer el build sin ella y nunca la envíe al navegador. La página de la documentación de Astro explica cómo crear el formulario y la clave.

archivos
src/
  actions/index.ts
  components/FirstTouch.astro
  layouts/Base.astro
  pages/
    pricing.astro
    contact-sales.astro
    contact-sales/thanks.astro
astro.config.mjs
.env
astro.config.mjs
import { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});

Sin output definido, todas las páginas siguen prerenderizadas y solo las que exportan prerender = false se ejecutan en el servidor. Añade los campos de abajo en la pestaña Fields del formulario, para que Ikiform compruebe las mismas reglas y rechace todo lo demás.

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.

El primer contacto en un sitio estático

src/components/FirstTouch.astro
<script>
  const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

  if (!document.cookie.split("; ").some((pair) => pair.startsWith("lead_source="))) {
    const params = new URLSearchParams(location.search);
    const from = URL.canParse(document.referrer) ? new URL(document.referrer) : null;
    const external = from && from.host !== location.host ? from.href.slice(0, 300) : "";
    const source = new URLSearchParams();
    for (const tag of TAGS) source.set(tag, (params.get(tag) ?? "").slice(0, 100));

    if (external || TAGS.some((tag) => source.get(tag))) {
      source.set("referrer", external);
      source.set("landing_page", location.pathname.slice(0, 200));
      document.cookie = `lead_source=${encodeURIComponent(source.toString())}; path=/; max-age=2592000; samesite=lax`;
    }
  }
</script>
  • Pon <FirstTouch /> en el layout que usan todas las páginas. Astro empaqueta el script una vez y lo ejecuta en cada carga de página; la comprobación de la cookie hace que solo cuente la primera visita.
  • La cookie no es httpOnly, porque la pone un script. Trátala como una pista: la action solo lee cinco claves conocidas e Ikiform limita la longitud de cada campo.
  • Se ignora un referrer de tu propio host, así que pasar de Pricing a Contact no sobrescribe la fuente real.
  • Si tus landing pages se renderizan bajo demanda, la misma lógica cabe en src/middleware.ts con context.cookies.set(). Comprueba primero context.isPrerendered y sáltalas.

La action

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export const server = {
  submitLead: defineAction({
    accept: "form",
    input: 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).nullish(),
      _gotcha: z.string().nullish(),
    }),
    handler: async (lead, context) => {
      const stored = new URLSearchParams(context.cookies.get("lead_source")?.value ?? "");
      const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
      const qualified =
        lead.company_size !== "1_10" &&
        ["10k_50k", "50k_plus"].includes(lead.budget) &&
        ["this_quarter", "next_quarter"].includes(lead.timeline);

      const origin = context.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 ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({ ...lead, ...source, fit: qualified ? "qualified" : "nurture" }),
      });
      const result = await response.json().catch(() => null);
      if (!result?.ok) {
        throw new ActionError({
          code: response.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
          message: result?.error?.message ?? "We couldn't send that. Email sales@example.com instead.",
        });
      }

      if (!qualified || lead._gotcha) return { next: "/contact-sales/thanks" };
      return { next: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` };
    },
  }),
};
  • Con accept: "form", Astro convierte un input vacío en null antes de que Zod lo vea. Por eso un select sin elegir falla en z.enum con tu propio mensaje, y el texto opcional necesita nullish().
  • 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. En astro dev es http://localhost:4321.
  • La action devuelve adónde ir a continuación en lugar de redirigir por sí misma. Las actions no pueden llamar a redirect; lo hace la página con el resultado.
  • El 429 de Ikiform se convierte en TOO_MANY_REQUESTS, así que la página puede distinguir un formulario saturado de un dato incorrecto.

La página de contacto

src/pages/contact-sales.astro
---
import { actions, isInputError } from "astro:actions";
import Base from "../layouts/Base.astro";

export const prerender = false;

const result = Astro.getActionResult(actions.submitLead);
if (result && !result.error) return Astro.redirect(result.data.next, 303);

const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
const failure = result?.error && !isInputError(result.error) ? result.error.message : "";
---

<Base title="Talk to sales">
  <form method="POST" action={actions.submitLead}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required />
    {fields.name && <p role="alert">{fields.name[0]}</p>}

    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {fields.email && <p role="alert">{fields.email[0]}</p>}

    <label for="company">Company</label>
    <input id="company" name="company" autocomplete="organization" required />
    {fields.company && <p role="alert">{fields.company[0]}</p>}

    <label for="company_size">Company size</label>
    <select id="company_size" name="company_size" required>
      <option value="">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>
    {fields.company_size && <p role="alert">{fields.company_size[0]}</p>}

    <label for="budget">Budget</label>
    <select id="budget" name="budget" required>
      <option value="">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>
    {fields.budget && <p role="alert">{fields.budget[0]}</p>}

    <label for="timeline">When do you want to start?</label>
    <select id="timeline" name="timeline" required>
      <option value="">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>
    {fields.timeline && <p role="alert">{fields.timeline[0]}</p>}

    <label for="message">Anything we should know?</label>
    <textarea id="message" name="message" rows="4"></textarea>

    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {failure && <p role="alert">{failure}</p>}
    <button>Talk to sales</button>
  </form>
</Base>

<script>
  const form = document.querySelector("form");
  form?.addEventListener("submit", () => {
    const button = form.querySelector("button");
    if (button) {
      button.disabled = true;
      button.textContent = "Sending";
    }
  });
</script>

El formulario se envía a la manera del HTML normal, así que funciona con JavaScript desactivado. Astro ejecuta la action, renderiza esta misma página con el resultado, y el frontmatter redirige con un 303 o muestra los errores. isInputError separa los mensajes por campo de Zod de los fallos propios de la action. El pequeño script solo desactiva el botón, para que un doble clic no envíe el lead dos veces.

Honeypot y verificación humana

El honeypot forma parte del esquema y se reenvía, así que Ikiform guarda como spam uno relleno, y la action lo manda a la página de agradecimiento en lugar de a tu calendario. La clave secreta se salta la verificación humana de Ikiform. Si los bots encuentran la página, verifica un token de Turnstile en el handler, como en Turnstile en un formulario.

Notas de despliegue

HostQué debes saber
Vercel@astrojs/vercel ejecuta la página de contacto y las actions como funciones y sirve el resto como archivos estáticos. Añade IKIFORM_SECRET_KEY en las variables de entorno del proyecto.
Netlify@astrojs/netlify hace lo mismo con Netlify Functions. Pon la clave en las variables de entorno del sitio.
Cloudflare@astrojs/cloudflare ejecuta las páginas bajo demanda en un Worker. Guarda la clave como secreto con wrangler secret put; astro:env la lee en tiempo de ejecución.

Comprueba que funciona

Los envíos desde astro dev 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 astro dev, abre /pricing?utm_source=test&utm_campaign=launch y comprueba la cookie lead_source en las herramientas de desarrollo.
  2. Ve a /contact-sales, deja Budget vacío y envía con JavaScript desactivado. La página se recarga con el error del presupuesto.
  3. Envía como lead cualificado: de 11 a 50 personas, más de 50k $, el próximo trimestre. Llegas a Cal.com con el nombre y el email rellenos.
  4. Envía con Researching for now. Llegas a /contact-sales/thanks.
  5. Abre Submissions en el panel y comprueba utm_source, referrer, landing_page y fit en los dos leads.
  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
El formulario se envía y la página no muestra nada nuevoLa página de contacto está prerenderizada. Añádele export const prerender = false; la documentación de Astro exige renderizado bajo demanda para las form actions.
Todos los campos se vacían tras un errorAstro vacía los inputs al enviar un formulario. Para conservarlos, activa las view transitions con <ClientRouter /> y añade transition:persist a cada input.
Los campos UTM están vacíos aunque el enlace tenía etiquetasLa cookie ya existía de una visita anterior, o el layout con <FirstTouch /> no se usa en la landing page. Borra la cookie y vuelve a probar.
IKIFORM_SECRET_KEY is missing durante el buildastro:env valida el esquema. Define la variable en .env en local y en los ajustes de tu hosting.
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.

Preguntas

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