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

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

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

Diagrama: Dónde ejecuta Astro cada parte de un formulario de leads

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

`archivos`:

```text
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`:

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

## El primer contacto en un sitio estático

`src/components/FirstTouch.astro`:

```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`:

```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`:

```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](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md).

## Notas de despliegue

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

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

| Problema | Qué hacer |
| --- | --- |
| El formulario se envía y la página no muestra nada nuevo | La 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 error | Astro 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 etiquetas | La 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 build | `astro:env` valida el esquema. Define la variable en `.env` en local y en los ajustes de tu hosting. |
| `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

### ¿Puedo registrar parámetros UTM en un sitio Astro estático?

Sí, pero no en el middleware, que en las páginas prerenderizadas se ejecuta durante el build. Un pequeño script en tu layout lee las etiquetas y el referrer en la primera visita y los guarda en una cookie, y la action lee la cookie cuando el lead envía el formulario.

### ¿Funciona una form action de Astro sin JavaScript?

Sí. Un formulario con method POST y action apuntando a la action se envía a la manera del HTML normal. La página debe renderizarse bajo demanda, y lee el resultado con Astro.getActionResult().

### ¿Cómo redirijo después de que una Astro Action tenga éxito?

Devuelve la URL desde el handler y, en el frontmatter de la página, comprueba Astro.getActionResult() y devuelve Astro.redirect() con esa URL cuando no haya error. Una URL absoluta, como tu enlace de Cal.com, funciona.

### ¿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 Next.js](https://ikiform.com/es/guides/lead-capture/nextjs.md): Un formulario de leads en el App Router de Next.js que valida con Zod en una server action, guarda los UTMs del primer contacto en una cookie y envía los leads cualificados a tu calendario.
- [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.
