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.
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.
- Durante el build, el middleware se ejecuta para las páginas prerenderizadas sin datos de petición, así que no puede registrar una visita.
- En el navegador, el script FirstTouch lee las etiquetas UTM y el referrer en la primera vista de página.
- Los guarda una vez en una cookie lead_source que llevan todas las peticiones posteriores.
- El formulario de contacto envía a la action con un formulario HTML normal, sin necesidad de JavaScript.
- La action valida con Zod, lee la cookie y reenvía el lead a Ikiform.
- 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.
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.
src/
actions/index.ts
components/FirstTouch.astro
layouts/Base.astro
pages/
pricing.astro
contact-sales.astro
contact-sales/thanks.astro
astro.config.mjs
.envimport { 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 |
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El primer contacto en un sitio estático
<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.tsconcontext.cookies.set(). Comprueba primerocontext.isPrerenderedy sáltalas.
La action
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 ennullantes de que Zod lo vea. Por eso unselectsin elegir falla enz.enumcon tu propio mensaje, y el texto opcional necesitanullish(). - 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. Enastro deveshttp://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
429de Ikiform se convierte enTOO_MANY_REQUESTS, así que la página puede distinguir un formulario saturado de un dato incorrecto.
La página de contacto
---
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
| 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.
- Ejecuta
astro dev, abre/pricing?utm_source=test&utm_campaign=launchy comprueba la cookielead_sourceen las herramientas de desarrollo. - Ve a
/contact-sales, deja Budget vacío y envía con JavaScript desactivado. La página se recarga con el error del presupuesto. - 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.
- Envía con Researching for now. Llegas a
/contact-sales/thanks. - Abre Submissions en el panel y comprueba
utm_source,referrer,landing_pageyfiten los dos leads. - 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
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.
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().
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.
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.