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 React Router

Um formulário de leads em React Router no framework mode: middleware na raiz com um cookie de primeiro toque assinado, uma action de rota que valida e um redirecionamento ao seu agendador.

Preet SutharFundador, Ikiform

Registre as tags UTM e o referrer com um middleware na raiz na primeira requisição de documento, num cookie assinado criado com createCookie. Valide o lead na action da rota, encaminhe-o com a atribuição para o seu backend de formulários e devolva redirect() para o seu agendador no caso de leads qualificados, ou data() com os erros de campo para o resto. <Form> envia com ou sem JavaScript, então a mesma action atende os dois casos.

A aba React Router do guia principal encaminha um lead a partir de uma action de rota e renderiza uma frase de agradecimento. Esta página monta a versão de vendas no framework mode: três perguntas de qualificação conferidas no servidor, a primeira campanha que trouxe o visitante e um redirecionamento para a sua agenda para os leads que se encaixam.

O que muda no React Router

As APIs de dados do React Router foram pensadas em torno de formulários HTML simples. <Form> envia à action da rota do mesmo jeito com ou sem JavaScript, e o que a action devolver, data() com erros ou redirect(), funciona nos dois casos. Com JavaScript, o React Router segue um redirect para outra origem com um carregamento completo do documento. Você escreve o caminho no servidor uma vez só.

Mais duas peças servem para este trabalho. O middleware, ligado por padrão desde o React Router 8, roda em toda requisição de documento, então a rota raiz pode registrar o primeiro toque. E createCookie assina o valor com o seu segredo, para que um visitante não consiga reescrever a própria atribuição antes de ela chegar ao seu CRM.

A mesma action do React Router atendendo um lead com e sem JavaScriptprimeira visitacom JavaScriptsem JavaScriptGET dedocumento?utm_campaign=...middleware daraizawait next(),depois Set-Cookiecookieassinadolead_source<Form>busca .dataactionvalida, envia,decideredirect()carrega o Cal.com<form>POST nativoactionmesma função303 Locationnavegador seguepáginare-renderizaerros e valorescreateCookiePOST303POST303data(..., 400)A mesma action do React Router atendendo um lead com e sem JavaScriptprimeira visitacom JavaScriptsem JavaScriptGET dedocumento?utm_campaign=...middleware daraizawait next(), depoisSet-Cookiecookie assinadolead_source<Form>busca .dataactionvalida, envia,decideredirect()carrega o Cal.com<form>POST nativoactionmesma função303 Locationnavegador seguepáginare-renderizaerros evalorescreateCookiePOST303POST303data(..., 400)
  1. A primeira requisição de documento traz as tags de campanha na URL e o referrer externo no header.
  2. O middleware da raiz deixa a página renderizar e depois acrescenta um Set-Cookie assinado com a origem.
  3. Com JavaScript, o Form envia à action por uma requisição de dados e o React Router segue o redirect com um carregamento de página completo.
  4. Sem JavaScript, o navegador envia de forma nativa e segue o 303 sozinho.
  5. Em qualquer caso, uma falha de validação devolve dados com status 400 e a página renderiza os erros ao lado dos valores digitados.
Uma action, dois transportes. O middleware só roda em requisições de documento, que é onde acontece o primeiro toque.

Configuração do projeto

Comece de um app no framework mode do React Router 8. Adicione duas variáveis só do servidor: a chave secreta do Ikiform e um segredo para assinar o cookie. Enviar a partir de um servidor cobre a criação da chave.

arquivos
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
.env
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-string

Depois adicione os campos na aba Fields do formulário. O Ikiform roda as mesmas verificações de novo e rejeita chaves que não conhece.

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.

Um cookie de primeiro toque assinado

app/lead-source.server.ts
import { createCookie } from "react-router";

export interface LeadSource {
  utm_source: string;
  utm_medium: string;
  utm_campaign: string;
  referrer: string;
  landing_page: string;
}

export const leadSource = createCookie("lead_source", {
  httpOnly: true,
  sameSite: "lax",
  path: "/",
  maxAge: 60 * 60 * 24 * 30,
  secrets: [process.env.COOKIE_SECRET ?? ""],
});

export async function firstTouch(request: Request): Promise<LeadSource | null> {
  const url = new URL(request.url);
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const tag = (key: string) => url.searchParams.get(key)?.slice(0, 100) ?? "";
  const source: LeadSource = {
    utm_source: tag("utm_source"),
    utm_medium: tag("utm_medium"),
    utm_campaign: tag("utm_campaign"),
    referrer: from && from.host !== url.host ? from.href.slice(0, 300) : "",
    landing_page: url.pathname.slice(0, 200),
  };
  const hasSignal = source.referrer || source.utm_source || source.utm_medium || source.utm_campaign;
  return hasSignal ? source : null;
}
app/root.tsx
import { leadSource, firstTouch } from "./lead-source.server";
import type { Route } from "./+types/root";

const recordFirstTouch: Route.MiddlewareFunction = async ({ request }, next) => {
  const response = await next();
  if (request.method !== "GET") return response;
  if (await leadSource.parse(request.headers.get("Cookie"))) return response;
  const source = await firstTouch(request);
  if (source) response.headers.append("Set-Cookie", await leadSource.serialize(source));
  return response;
};

export const middleware: Route.MiddlewareFunction[] = [recordFirstTouch];
  • O sufixo .server.ts mantém o módulo, e o COOKIE_SECRET, fora do bundle do cliente.
  • leadSource.parse devolve null para um cookie ausente e para um cuja assinatura não bate, então um valor adulterado é ignorado, não aceito.
  • O middleware chama next() primeiro e depois só adiciona um header. A documentação diz para ler o status e definir headers na resposta do servidor, sem mexer no corpo.
  • O middleware de servidor só roda em navegações no cliente quando elas buscam .data. Não tem problema: as tags chegam na primeira requisição de documento.

A action da rota

app/routes/contact-sales.tsx
import { data, Form, redirect, useNavigation } from "react-router";
import { type LeadSource, leadSource } from "../lead-source.server";
import type { Route } from "./+types/contact-sales";

const CHOICES = {
  company_size: ["1_10", "11_50", "51_200", "201_1000", "1000_plus"],
  budget: ["under_10k", "10k_50k", "50k_plus", "unsure"],
  timeline: ["this_quarter", "next_quarter", "later", "researching"],
};

function validate(values: Record<string, string>) {
  const errors: Record<string, string> = {};
  if (!values.name) errors.name = "Tell us who to reply to.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) errors.email = "Enter a work email we can reply to.";
  if (!values.company) errors.company = "Which company is this for?";
  for (const [key, allowed] of Object.entries(CHOICES)) {
    if (!allowed.includes(values[key])) errors[key] = "Pick one of the options.";
  }
  if (values.message.length > 5000) errors.message = "Keep it under 5,000 characters.";
  return errors;
}

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const keys = ["name", "email", "company", "company_size", "budget", "timeline", "message", "_gotcha"];
  const values = Object.fromEntries(keys.map((key) => [key, String(form.get(key) ?? "").trim()]));
  const errors = validate(values);
  if (Object.keys(errors).length) return data({ errors, values }, { status: 400 });

  const source: Partial<LeadSource> = (await leadSource.parse(request.headers.get("Cookie"))) ?? {};
  const qualified =
    values.company_size !== "1_10" &&
    ["10k_50k", "50k_plus"].includes(values.budget) &&
    ["this_quarter", "next_quarter"].includes(values.timeline);
  const origin = 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 ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify({ ...source, ...values, fit: qualified ? "qualified" : "nurture" }),
  });
  const result = await response.json().catch(() => null);
  if (!result?.ok) {
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const failed = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
    if (!Object.keys(failed).length) failed.form = "We couldn't send that. Email sales@example.com instead.";
    return data({ errors: failed, values }, { status: response.status });
  }

  if (!qualified || values._gotcha) return redirect("/contact-sales/thanks", 303);
  return redirect(`https://cal.com/acme/intro?${new URLSearchParams({ name: values.name, email: values.email })}`, 303);
}

export default function ContactSales({ actionData }: Route.ComponentProps) {
  const pending = useNavigation().state === "submitting";
  const errors: Record<string, string | undefined> = actionData?.errors ?? {};
  const values: Record<string, string | undefined> = actionData?.values ?? {};
  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
      {error("company")}
      <label htmlFor="company_size">Company size</label>
      <select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""}>
        <option value="" disabled>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>
      {error("company_size")}
      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue={values.budget ?? ""}>
        <option value="" disabled>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>
      {error("budget")}
      <label htmlFor="timeline">When do you want to start?</label>
      <select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""}>
        <option value="" disabled>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>
      {error("timeline")}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />
      {error("message")}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </Form>
  );
}
  • A validação é uma pequena função sem dependências. Troque pelo Zod se você já o usa; o formato da action continua o mesmo.
  • data({ errors, values }, { status: 400 }) manda um status de erro de verdade, o que também impede o React Router de revalidar os loaders num envio que falhou.
  • 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.
  • redirect() aceita URLs absolutas. A documentação avisa para validar entradas do usuário em destinos de redirect; aqui só a query string vem do visitante, e URLSearchParams a codifica.
  • values é espalhado depois de source, mas todas as chaves são diferentes, então o cookie não consegue sobrescrever um campo do formulário.

Interface de pendente e de erro

useNavigation().state fica submitting enquanto a action roda, o que desativa o botão. Os inputs leem defaultValue de actionData.values, então um envio nativo que volta com erros ainda mostra tudo o que o visitante digitou. Com JavaScript, o formulário nem é resetado.

Honeypot e verificação humana

O honeypot vai junto com os outros valores, o Ikiform armazena um preenchido como spam, e a action o manda para a rota de agradecimento. A chave secreta pula a verificação humana do Ikiform, então valide um token do Turnstile na action se precisar, como em Turnstile num formulário.

Renderização e deploy

Actions precisam de um servidor em tempo de execução. Com ssr: false em react-router.config.ts, o build recusa qualquer rota com action. Os caminhos listados em prerender viram arquivos estáticos, e um host que serve esses arquivos nunca roda o middleware, então deixe suas landing pages de campanha renderizadas no servidor.

HostO que saber
VercelAdicione o preset @vercel/react-router em react-router.config.ts. Defina os dois segredos nas variáveis de ambiente do projeto.
NetlifyAdicione @netlify/vite-plugin-react-router. Defina os dois segredos nas configurações do site.
CloudflareUse @cloudflare/vite-plugin; o React Router 8 removeu o próprio proxy de desenvolvimento para Cloudflare. Guarde os dois segredos com wrangler secret put e leia-os do jeito que o seu template de Worker expõe o env.

Confira se funciona

Envios do servidor de desenvolvimento 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 react-router dev, abra /?utm_source=test&utm_campaign=launch e encontre o cookie assinado lead_source nas ferramentas de desenvolvedor.
  2. Edite o valor do cookie à mão e envie um lead. Os campos UTM chegam vazios, porque a assinatura não bate mais.
  3. Desligue o JavaScript, deixe Timeline vazio e envie. A página recarrega com o erro e os seus valores.
  4. Envie como lead qualificado: 51 a 200 pessoas, mais de $50k, próximo trimestre. Você cai no Cal.com com nome e e-mail preenchidos.
  5. Abra Submissions no painel e confira utm_campaign, referrer e fit.
  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
middleware não tem efeitoNo React Router 7 ele ficava atrás da flag future.v8_middleware. Ligue-a, ou atualize para o 8, onde ele é o padrão.
O build falha com funções action proibidasssr: false está definido. Ligue o SSR de novo no app; pré-renderizar caminhos específicos continua funcionando junto.
O cookie nunca fica salvoCOOKIE_SECRET está vazio ou muda a cada deploy, então toda assinatura falha. Defina um valor estável.
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.
402 test_limit_reachedO workspace não tem plano e já usou os 50 testes gratuitos de localhost deste mês. Escolha um plano ou use Send test no painel.

Perguntas

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