Formulário de captação de leads8 min de leitura
Crie um formulário de leads com qualificação no Astro
Um formulário de leads em Astro feito com Actions: validação com Zod, UTMs de primeiro toque num site estático, uma página de contato sob demanda e redirecionamento ao seu agendador.
Mantenha suas páginas de marketing pré-renderizadas e registre as tags UTM e o referrer da primeira visita num pequeno cookie a partir de um script no cliente, porque o middleware numa página pré-renderizada só roda no build. Renderize a página de contato sob demanda, valide o lead numa Astro Action com astro/zod e encaminhe-o com a atribuição para o seu backend de formulários. Devolva a próxima URL pela action e chame Astro.redirect() para mandar leads qualificados ao seu agendador.
A aba Astro do guia principal envia um lead por uma action e mostra uma frase de agradecimento. Esta página o transforma num formulário de vendas: tamanho da empresa, orçamento e prazo conferidos pelo Zod no seu servidor, dados de campanha que sobrevivem a um site estático e uma passagem para a sua agenda para os leads que se encaixam.
O que muda no Astro
O Astro pré-renderiza as páginas por padrão, e isso molda tudo na atribuição. O middleware roda para uma página pré-renderizada uma única vez, no build, sem visitante, sem cookies e sem query string. A landing page onde ?utm_source= de fato aparece quase sempre é pré-renderizada, então o middleware nunca vê as tags.
Por isso o primeiro toque é registrado no navegador, por um script curto no seu layout, e lido de volta no servidor pela action. A página de contato é a única que precisa renderizar sob demanda, porque a documentação do Astro exige isso de toda página que chama uma action a partir de um formulário.
- No build, o middleware roda para páginas pré-renderizadas sem dados de requisição, então não consegue registrar uma visita.
- No navegador, o script FirstTouch lê as tags UTM e o referrer na primeira visualização de página.
- Ele as guarda uma vez num cookie lead_source que toda requisição posterior leva junto.
- O formulário de contato envia à action com um formulário HTML simples, sem precisar de JavaScript.
- A action valida com Zod, lê o cookie e encaminha o lead ao Ikiform.
- A página lê o resultado e redireciona para o agendador ou para a página de agradecimento; erros de entrada aparecem abaixo dos campos.
Configuração do projeto
Adicione um adapter com npx astro add vercel (ou netlify, ou cloudflare) e depois declare a chave com astro:env, para que o Astro se recuse a fazer o build sem ela e nunca a envie ao navegador. A página da documentação sobre Astro cobre a criação do formulário e da chave.
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" }),
},
},
});Sem output definido, todas as páginas continuam pré-renderizadas e só as que exportam prerender = false rodam no servidor. Adicione os campos abaixo na aba Fields do formulário, para que o Ikiform confira as mesmas regras e rejeite todo o resto.
| Id do campo | Tipo no Ikiform | Definido por |
|---|---|---|
name, email, company | Text, email, text | O visitante |
company_size | Select, com as opções 1_10, 11_50, 51_200, 201_1000, 1000_plus | O visitante |
budget | Select, com as opções under_10k, 10k_50k, 50k_plus, unsure | O visitante |
timeline | Select, com as opções this_quarter, next_quarter, later, researching | O visitante |
message | Textarea, opcional | O visitante |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Text, opcional | Seu servidor, a partir da primeira visita |
fit | Select, com as opções qualified, nurture | Seu servidor, após a validação |
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.Primeiro toque num site 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>- Coloque
<FirstTouch />no layout que todas as páginas usam. O Astro empacota o script uma vez e o roda a cada carregamento de página; a verificação do cookie faz só a primeira visita contar. - O cookie não é
httpOnly, porque é um script que o define. Trate-o como uma indicação: a action só lê cinco chaves conhecidas e o Ikiform limita o tamanho de todo campo. - Um referrer do seu próprio host é ignorado, então clicar de Pricing para Contact não sobrescreve a origem real.
- Se as suas landing pages renderizam sob demanda, a mesma lógica cabe em
src/middleware.tscomcontext.cookies.set(). Confiracontext.isPrerenderedantes e pule.
A 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 })}` };
},
}),
};- Com
accept: "form", o Astro transforma um input vazio emnullantes de o Zod vê-lo. Umselectsem escolha, portanto, falha emz.enumcom a sua própria mensagem, e textos opcionais precisam denullish(). - O header
Origindo navegador é repassado, porque um fetch no servidor não manda nenhum e o Ikiform detecta por ele os testes gratuitos emlocalhost. Noastro devele éhttp://localhost:4321. - A action devolve para onde ir em seguida em vez de redirecionar ela mesma. Actions não podem chamar
redirect; a página faz isso com o resultado. - O
429do Ikiform viraTOO_MANY_REQUESTS, para a página distinguir um formulário sobrecarregado de uma entrada inválida.
A página de contato
---
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>O formulário envia do jeito HTML tradicional, então funciona com o JavaScript desligado. O Astro roda a action, renderiza esta mesma página com o resultado, e o frontmatter redireciona com um 303 ou renderiza os erros. isInputError separa as mensagens por campo do Zod das falhas da própria action. O pequeno script só desativa o botão, para que um clique duplo não envie o lead duas vezes.
Honeypot e verificação humana
O honeypot faz parte do schema e é encaminhado, então o Ikiform armazena um preenchido como spam, e a action o manda para a página de agradecimento em vez da sua agenda. A chave secreta pula a verificação humana do próprio Ikiform. Se bots encontrarem a página, valide um token do Turnstile no handler, como em Turnstile num formulário.
Notas de deploy
| Host | O que saber |
|---|---|
| Vercel | @astrojs/vercel roda a página de contato e as actions como functions e serve o resto como arquivos estáticos. Adicione IKIFORM_SECRET_KEY nas variáveis de ambiente do projeto. |
| Netlify | @astrojs/netlify faz o mesmo com Netlify Functions. Defina a chave nas variáveis de ambiente do site. |
| Cloudflare | @astrojs/cloudflare roda as páginas sob demanda num Worker. Guarde a chave como secret com wrangler secret put; astro:env a lê em tempo de execução. |
Confira se funciona
Envios do astro dev trazem a origem localhost. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.
- Rode
astro dev, abra/pricing?utm_source=test&utm_campaign=launche confira o cookielead_sourcenas ferramentas de desenvolvedor. - Vá para
/contact-sales, deixe Budget vazio e envie com o JavaScript desligado. A página recarrega com o erro do orçamento. - Envie como lead qualificado: 11 a 50 pessoas, mais de $50k, próximo trimestre. Você cai no Cal.com com nome e e-mail preenchidos.
- Envie com Researching for now. Você cai em
/contact-sales/thanks. - Abra Submissions no painel e confira
utm_source,referrer,landing_pageefitnos dois leads. - Confira a caixa de entrada de cada destinatário das notificações. Cada um recebe um e-mail com os campos, e Reply vai para quem enviou quando o formulário tem um campo
email.
Pegadinhas
| Problema | O que fazer |
|---|---|
| O formulário envia e a página não mostra nada novo | A página de contato está pré-renderizada. Adicione export const prerender = false a ela; a documentação do Astro exige renderização sob demanda para form actions. |
| Todos os campos ficam vazios depois de um erro | O Astro limpa os inputs num envio de formulário. Para mantê-los, ative as view transitions com <ClientRouter /> e adicione transition:persist a cada input. |
| Os campos UTM estão vazios, embora o link tivesse tags | O cookie já existia de uma visita anterior, ou o layout com <FirstTouch /> não é usado na landing page. Apague o cookie e tente de novo. |
IKIFORM_SECRET_KEY is missing no build | astro:env valida o schema. Defina a variável no .env localmente e nas configurações do seu host. |
422 validation_failed | O envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo. |
Perguntas
Sim, mas não no middleware, que roda no build para páginas pré-renderizadas. Um pequeno script no seu layout lê as tags e o referrer na primeira visita e os guarda num cookie, e a action lê o cookie quando o lead é enviado.
Sim. Um formulário com method POST e action apontando para a action envia do jeito HTML tradicional. A página precisa renderizar sob demanda e lê o resultado com Astro.getActionResult().
Devolva a URL pelo handler e, no frontmatter da página, confira Astro.getActionResult() e retorne Astro.redirect() com essa URL quando não houver erro. Uma URL absoluta, como o seu link do Cal.com, funciona.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.