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

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/lead-capture/astro

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

Diagrama: Onde o Astro executa cada parte de um formulário de leads

1. No build, o middleware roda para páginas pré-renderizadas sem dados de requisição, então não consegue registrar uma visita.
2. No navegador, o script FirstTouch lê as tags UTM e o referrer na primeira visualização de página.
3. Ele as guarda uma vez num cookie lead_source que toda requisição posterior leva junto.
4. O formulário de contato envia à action com um formulário HTML simples, sem precisar de JavaScript.
5. A action valida com Zod, lê o cookie e encaminha o lead ao Ikiform.
6. 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.

Só a página de contato precisa de servidor. Tudo o que o visitante lê antes dela continua estático.

## 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](https://ikiform.com/pt-br/docs/guides/astro.md) cobre a criação do formulário e da chave.

`arquivos`:

```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" }),
    },
  },
});
```

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 |

> As versões no servidor enviam com uma chave secreta, então o Ikiform pula **Allowed domains** e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o [limite de taxa](https://ikiform.com/pt-br/docs/rate-limits.md) Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha `IKIFORM_SECRET_KEY` em variáveis de ambiente só do servidor.

## Primeiro toque num site 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>
```

- 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.ts` com `context.cookies.set()`. Confira `context.isPrerendered` antes e pule.

## A 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 })}` };
    },
  }),
};
```

- Com `accept: "form"`, o Astro transforma um input vazio em `null` antes de o Zod vê-lo. Um `select` sem escolha, portanto, falha em `z.enum` com a sua própria mensagem, e textos opcionais precisam de `nullish()`.
- O header `Origin` do navegador é repassado, porque um fetch no servidor não manda nenhum e o Ikiform detecta por ele os testes gratuitos em `localhost`. No `astro dev` ele é `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 `429` do Ikiform vira `TOO_MANY_REQUESTS`, para a página distinguir um formulário sobrecarregado de uma entrada inválida.

## A página de contato

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

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

## 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](https://ikiform.com/pt-br/pricing).

1. Rode `astro dev`, abra `/pricing?utm_source=test&utm_campaign=launch` e confira o cookie `lead_source` nas ferramentas de desenvolvedor.
2. Vá para `/contact-sales`, deixe **Budget** vazio e envie com o JavaScript desligado. A página recarrega com o erro do orçamento.
3. 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.
4. Envie com **Researching for now**. Você cai em `/contact-sales/thanks`.
5. Abra **Submissions** no painel e confira `utm_source`, `referrer`, `landing_page` e `fit` nos dois leads.
6. 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

### Dá para rastrear parâmetros UTM num site Astro estático?

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.

### Uma form action do Astro funciona sem JavaScript?

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().

### Como redireciono depois que uma Astro Action dá certo?

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.

### O Ikiform é gratuito?

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.

## Mais guias

- [Formulário de captação de leads](https://ikiform.com/pt-br/guides/lead-capture.md): Um formulário de leads no seu próprio site que envia e-mail com reply-to, avisa o chat da equipe e alimenta seu CRM, para você responder enquanto o lead ainda está lá.
- [Formulário de leads em Next.js](https://ikiform.com/pt-br/guides/lead-capture/nextjs.md): Um formulário de leads no App Router do Next.js que valida com Zod numa server action, guarda as UTMs do primeiro toque num cookie e manda leads qualificados para a sua agenda.
- [Formulário de leads em SvelteKit](https://ikiform.com/pt-br/guides/lead-capture/sveltekit.md): Um formulário de leads em SvelteKit: o hook handle registra as UTMs de primeiro toque, uma form action valida com Valibot e redirect() manda leads qualificados para uma agenda.
- [Formulário de pedido de demonstração](https://ikiform.com/pt-br/guides/lead-capture/demo-request.md): Um formulário de agendar demonstração que armazena o lead e depois manda o visitante para o Cal.com ou o Calendly com nome e e-mail já preenchidos.
- [Formulário de pedido de orçamento](https://ikiform.com/pt-br/guides/lead-capture/quote-request.md): Um formulário de pedido de orçamento que recebe briefings como links compartilhados em vez de uploads, coloca um prazo em cada pedido e mantém leads cheios de links fora do spam.
- [Formulário de pedido de consulta](https://ikiform.com/pt-br/guides/lead-capture/consultation-request.md): Um formulário de pedido de consulta para contadores, advogados, coaches e clínicas que pergunta como e quando ligar, e deixa os detalhes sensíveis para a conversa.
