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

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

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

Diagrama: A mesma action do React Router atendendo um lead com e sem JavaScript

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](https://ikiform.com/pt-br/docs/guides/server.md) cobre a criação da chave.

`arquivos`:

```text
app/
  root.tsx
  lead-source.server.ts
  routes.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
react-router.config.ts
.env
```

`.env`:

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

## Um cookie de primeiro toque assinado

`app/lead-source.server.ts`:

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

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

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

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

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

| 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

### Uma action do React Router pode redirecionar para outro domínio?

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.

### Como defino um cookie a partir de um middleware do React Router?

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[].

### Um formulário de leads em React Router funciona sem JavaScript?

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.

### 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 TanStack Start](https://ikiform.com/pt-br/guides/lead-capture/tanstack-start.md): Um formulário de leads em TanStack Start: middleware de requisição global para UTMs de primeiro toque, uma server function com Zod e um redirect lançado para o seu agendador.
- [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.
