Formulário de captação de leads

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.

Preet SutharFundador, Ikiform

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.

Onde o Astro executa cada parte de um formulário de leadsatribuiçãoenviarmiddlewareroda uma vez,sem visitantescriptFirstTouchlê ?utm_ ereferrercookielead_sourceenviado com opostPOST doformulárioaction={actions.submitLead}Astro ActionZod, lê o cookieAstro.redirectCal.com ou/thanksmesma páginamensagem em cadacampodefinedados do formulárioresult.data.nextisInputErrorbuildno navegadorsob demandaOnde o Astro executa cada parte de um formulário de leadsatribuiçãoenviarmiddlewareroda uma vez, semvisitantescriptFirstTouchlê ?utm_ e referrercookielead_sourceenviado com o postPOST doformulárioaction={actions.submitLead}Astro ActionZod, lê o cookieAstro.redirectCal.com ou /thanksmesmapáginamensagem emcada campodefinedados do formulárioresult.data.nextisInputError
  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 cobre a criação do formulário e da chave.

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

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 campoTipo no IkiformDefinido por
name, email, companyText, email, textO visitante
company_sizeSelect, com as opções 1_10, 11_50, 51_200, 201_1000, 1000_plusO visitante
budgetSelect, com as opções under_10k, 10k_50k, 50k_plus, unsureO visitante
timelineSelect, com as opções this_quarter, next_quarter, later, researchingO visitante
messageTextarea, opcionalO visitante
utm_source, utm_medium, utm_campaign, referrer, landing_pageText, opcionalSeu servidor, a partir da primeira visita
fitSelect, com as opções qualified, nurtureSeu 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 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
<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
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
---
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

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

  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

ProblemaO que fazer
O formulário envia e a página não mostra nada novoA 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 erroO 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 tagsO 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 buildastro:env valida o schema. Defina a variável no .env localmente e nas configurações do seu host.
422 validation_failedO 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

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.