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.
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 primeira requisição de documento traz as tags de campanha na URL e o referrer externo no header.
- O middleware da raiz deixa a página renderizar e depois acrescenta um Set-Cookie assinado com a origem.
- 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.
- Sem JavaScript, o navegador envia de forma nativa e segue o 303 sozinho.
- 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.
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.
app/
root.tsx
lead-source.server.ts
routes.ts
routes/
contact-sales.tsx
contact-sales.thanks.tsx
react-router.config.ts
.envIKIFORM_SECRET_KEY=ik_sk_your_secret_key
COOKIE_SECRET=a-long-random-stringDepois 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 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.Um cookie de primeiro toque assinado
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;
}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.tsmantém o módulo, e oCOOKIE_SECRET, fora do bundle do cliente. leadSource.parsedevolvenullpara 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
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
Origindo navegador é repassado, porque um fetch no servidor não manda nenhum e o Ikiform detecta por ele os testes gratuitos emlocalhost. 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, eURLSearchParamsa codifica.valuesé espalhado depois desource, 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.
| Host | O que saber |
|---|---|
| Vercel | Adicione o preset @vercel/react-router em react-router.config.ts. Defina os dois segredos nas variáveis de ambiente do projeto. |
| Netlify | Adicione @netlify/vite-plugin-react-router. Defina os dois segredos nas configurações do site. |
| Cloudflare | Use @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.
- Rode
react-router dev, abra/?utm_source=test&utm_campaign=launche encontre o cookie assinadolead_sourcenas ferramentas de desenvolvedor. - Edite o valor do cookie à mão e envie um lead. Os campos UTM chegam vazios, porque a assinatura não bate mais.
- Desligue o JavaScript, deixe Timeline vazio e envie. A página recarrega com o erro e os seus valores.
- 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.
- Abra Submissions no painel e confira
utm_campaign,referrerefit. - 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 |
|---|---|
middleware não tem efeito | No 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 proibidas | ssr: false está definido. Ligue o SSR de novo no app; pré-renderizar caminhos específicos continua funcionando junto. |
| O cookie nunca fica salvo | COOKIE_SECRET está vazio ou muda a cada deploy, então toda assinatura falha. Defina um valor estável. |
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. |
402 test_limit_reached | O 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
Sim. redirect() aceita uma URL absoluta. Um envio de formulário nativo segue o header Location sozinho, e com JavaScript o React Router faz uma navegação completa de documento para a outra origem.
Chame next() para obter a resposta e depois acrescente um header Set-Cookie feito com cookie.serialize() de createCookie. Exporte o middleware da rota, como root.tsx, como Route.MiddlewareFunction[].
Sim. Form renderiza um elemento form normal, a action roda num envio nativo, e devolver data() com erros ou redirect() funciona igual a com JavaScript.
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.