# Crie um formulário de leads com qualificação no TanStack Start

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

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

Registre as tags UTM e o referrer na primeira requisição de página com um middleware de requisição global em `src/start.ts` e guarde-os num cookie httpOnly. Valide o lead num handler `createServerFn` com Zod, encaminhe-o com a atribuição para o seu backend de formulários e lance `redirect({ href })` para mandar leads qualificados ao seu agendador. Chame a server function via `useServerFn` para que o redirecionamento seja seguido por você.

A [aba TanStack Start do guia principal](https://ikiform.com/pt-br/guides/lead-capture.md#form) envia um lead por uma server function e mostra uma frase de agradecimento. Esta página monta a versão de vendas: três perguntas de qualificação validadas no servidor, a campanha que trouxe o visitante e um redirecionamento direto para a sua agenda quando o lead se encaixa.

## O que muda no TanStack Start

O Start não tem convenção de middleware baseada em arquivo. Tudo o que deve rodar em toda requisição vai em `src/start.ts` como middleware de requisição global, que roda antes de SSR, server routes e server functions. É ali que o primeiro toque é registrado.

O detalhe: o Start só instala automaticamente a proteção CSRF para server functions quando `src/start.ts` não existe. No momento em que você cria o arquivo para a atribuição, essa proteção passa a ser sua e você precisa adicionar `createCsrfMiddleware()` por conta própria. Esqueça disso e o seu endpoint de leads aceita envios de outros sites.

Diagrama: Como um lead passa pelo middleware e pela server function do TanStack Start

1. Toda requisição passa primeiro pelo middleware CSRF; ele só confere chamadas de server function.
2. Num GET ainda sem cookie, firstTouch lê as tags UTM e um referrer externo e os guarda.
3. A página renderiza normalmente.
4. No envio, useServerFn manda o FormData para a server function.
5. O validator o transforma num objeto simples e o handler roda o Zod nele.
6. Um lead válido vai ao Ikiform com a atribuição, o fit e o Origin do visitante.
7. O handler lança um redirect para o Cal.com ou para a rota de agradecimento; useServerFn o segue. Os erros voltam como dados.

Escrever src/start.ts substitui o middleware de requisição padrão do Start, então o CSRF volta à mão.

## Configuração do projeto

Comece de um projeto TanStack Start com React e instale `zod`. Coloque a chave secreta no `.env` como `IKIFORM_SECRET_KEY`; as server functions a leem de `process.env`, e ela nunca chega ao bundle do cliente. Não há uma página específica do Start na documentação; [enviar a partir de um servidor](https://ikiform.com/pt-br/docs/guides/server.md) cobre a chave e o endpoint.

`arquivos`:

```text
src/
  start.ts
  lib/lead.ts
  routes/
    contact-sales.tsx
    contact-sales.thanks.tsx
vite.config.ts
.env
```

Defina os campos na aba **Fields** do formulário, para que o Ikiform rejeite tudo o que não bater, inclusive chaves de atribuição que você não planejou.

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

## Middleware global para o primeiro toque

`src/start.ts`:

```ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";

const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

const csrf = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" });

const firstTouch = createMiddleware().server(({ request, next }) => {
  if (request.method !== "GET" || getCookie("lead_source")) return next();

  const url = new URL(request.url);
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href.slice(0, 300) : "";
  const source = new URLSearchParams();
  for (const tag of TAGS) source.set(tag, url.searchParams.get(tag)?.slice(0, 100) ?? "");

  if (external || TAGS.some((tag) => source.get(tag))) {
    source.set("referrer", external);
    source.set("landing_page", url.pathname.slice(0, 200));
    setCookie("lead_source", source.toString(), {
      httpOnly: true,
      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24 * 30,
    });
  }
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [csrf, firstTouch],
}));
```

- O filtro mantém a verificação CSRF nas server functions, como a documentação do Start recomenda para uso global. Ele compara `Sec-Fetch-Site`, `Origin` ou `Referer` com a origem da requisição.
- `firstTouch` pula tudo o que não é GET, então os envios de server function nunca mexem no cookie.
- `setCookie` e `getCookie` vêm de `@tanstack/react-start/server` e só rodam no servidor.
- Um cookie definido durante uma requisição só fica legível por `getCookie` na próxima. Aqui isso não é problema: o lead é enviado numa requisição posterior.

## A server function

`src/lib/lead.ts`:

```ts
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, getRequestHeader } from "@tanstack/react-start/server";
import { z } from "zod";

const LeadSchema = 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).optional(),
  _gotcha: z.string().optional(),
});

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
const FIT = { size: ["1_10"], budget: ["10k_50k", "50k_plus"], timeline: ["this_quarter", "next_quarter"] };

export type LeadResult = { errors: Record<string, string> };

export const submitLead = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return Object.fromEntries([...data].map(([key, value]) => [key, String(value)]));
  })
  .handler(async ({ data }): Promise<LeadResult> => {
    const parsed = LeadSchema.safeParse(data);
    if (!parsed.success) {
      const { fieldErrors } = z.flattenError(parsed.error);
      return { errors: Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""])) };
    }
    const lead = parsed.data;
    const stored = new URLSearchParams(getCookie("lead_source") ?? "");
    const qualified =
      !FIT.size.includes(lead.company_size) && FIT.budget.includes(lead.budget) && FIT.timeline.includes(lead.timeline);
    const origin = getRequestHeader("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({
        ...lead,
        ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
        fit: qualified ? "qualified" : "nurture",
      }),
    });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      const fields: Record<string, string[]> = result?.error?.fields ?? {};
      const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
      return { errors: Object.keys(errors).length ? errors : { form: result?.error?.message ?? "Try again." } };
    }

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

- `.validator()` só remodela o `FormData`; o Zod roda no handler com `safeParse`, para que os erros de campo voltem como dados em vez de uma exceção que o componente não consegue ler por campo.
- `fit` é decidido no servidor. Editar o formulário não promove um lead.
- `getRequestHeader("origin")` é repassado, porque um fetch no servidor não manda `Origin` e o Ikiform detecta por ele os testes gratuitos em `localhost`.
- `redirect({ href })` é a forma externa; `redirect({ to })` fica dentro do app. Os dois são lançados.

## A rota

`src/routes/contact-sales.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { submitLead } from "../lib/lead";

export const Route = createFileRoute("/contact-sales")({ component: ContactSales });

const SELECTS = {
  company_size: ["Company size", [["1_10", "1 to 10"], ["11_50", "11 to 50"], ["51_200", "51 to 200"], ["201_1000", "201 to 1,000"], ["1000_plus", "More than 1,000"]]],
  budget: ["Budget", [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]]],
  timeline: ["When do you want to start?", [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]]],
} as const;

function ContactSales() {
  const send = useServerFn(submitLead);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      const result = await send({ data: new FormData(event.currentTarget) });
      setErrors(result?.errors ?? {});
    } catch {
      setErrors({ form: "We couldn't send that. Email sales@example.com instead." });
    } finally {
      setPending(false);
    }
  }

  const error = (key: string) => (errors[key] ? <p role="alert">{errors[key]}</p> : null);

  return (
    <form method="post" action={submitLead.url} encType="multipart/form-data" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {error("name")}
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {error("email")}
      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required />
      {error("company")}
      {Object.entries(SELECTS).map(([id, [label, options]]) => (
        <div key={id}>
          <label htmlFor={id}>{label}</label>
          <select id={id} name={id} required defaultValue="">
            <option value="" disabled>Choose one</option>
            {options.map(([value, text]) => (
              <option key={value} value={value}>{text}</option>
            ))}
          </select>
          {error(id)}
        </div>
      ))}
      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error("form")}
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

O handler de envio impede o post padrão e chama a server function via `useServerFn`, que é quem segue um redirect lançado, inclusive para outro site. Como o formulário nunca é resetado, todos os valores ficam no lugar enquanto o visitante corrige um campo.

O atributo `action={submitLead.url}` é o caminho sem JavaScript. O Start documenta `.url` para formulários HTML, mas aí o navegador mostra a resposta bruta da server function em vez desta página. Teste com o JavaScript desligado e decida se isso é aceitável, ou coloque um link de e-mail simples para vendas abaixo do formulário.

### Honeypot e verificação humana

Um honeypot preenchido ainda é enviado, então o Ikiform o armazena como spam, e o handler o manda para a rota de agradecimento em vez da sua agenda. A chave secreta pula a verificação humana do Ikiform; adicione uma verificação do Turnstile ao handler se bots encontrarem o formulário, seguindo [Turnstile num formulário](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md).

## Renderização e deploy

As rotas renderizam no servidor por padrão, e o middleware roda em cada uma dessas requisições. Se você pré-renderizar páginas de marketing em arquivos estáticos, a CDN as serve sem rodar o middleware, então a atribuição precisa começar numa página que renderiza a cada requisição.

| Host | O que saber |
| --- | --- |
| Vercel | Adicione o plugin `nitro/vite` e faça o deploy. Defina `IKIFORM_SECRET_KEY` nas variáveis de ambiente do projeto. |
| Netlify | Adicione `@netlify/vite-plugin-tanstack-start` ao `vite.config.ts`. Defina a chave nas configurações do site. |
| Cloudflare | Adicione `@cloudflare/vite-plugin` e faça o deploy com o Wrangler. Guarde a chave com `wrangler secret put` e ative `nodejs_compat` para que `process.env` a tenha. |

## 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 o servidor de desenvolvimento, abra `/?utm_source=test&utm_campaign=launch` e confira nas ferramentas de desenvolvedor que o cookie `lead_source` é httpOnly.
2. Em `/contact-sales`, escolha **Under $10k** e deixe **Company** vazio. O erro da empresa aparece e todos os outros valores continuam.
3. Envie como lead qualificado: 201 a 1.000 pessoas, $10k a $50k, este trimestre. O navegador vai para o Cal.com com nome e e-mail preenchidos.
4. Envie como uma equipe de 1 a 10 pessoas. Você cai em `/contact-sales/thanks`.
5. Abra **Submissions** no painel e confira `utm_source`, `landing_page` 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 |
| --- | --- |
| `403` da sua própria server function depois de adicionar `src/start.ts` | A verificação CSRF rejeitou a chamada. Requisições de outra origem pública, como atrás de um proxy, precisam de `createCsrfMiddleware({ origin })`. |
| Envios de outros sites chegam à server function | `src/start.ts` existe sem `createCsrfMiddleware()`. Adicione-o, como no exemplo. |
| A página não sai do lugar depois de um envio qualificado | A server function foi chamada diretamente, e não via `useServerFn`, então nada segue o redirect lançado. |
| Um erro sobre importar código de servidor no cliente | `getCookie` foi chamado fora de uma server function ou de um middleware. Mantenha-o dentro de `.handler()` ou `.server()`. |
| `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

### Como redireciono para uma URL externa a partir de uma server function do TanStack Start?

Lance redirect({ href: 'https://...' }) de @tanstack/react-router dentro do handler e chame a server function via useServerFn no componente. useServerFn segue o redirect, inclusive para outro site.

### Onde fica o middleware no TanStack Start?

O middleware de requisição global fica em src/start.ts, retornado por createStart como requestMiddleware. Ele roda antes de SSR, server routes e server functions. Criar esse arquivo significa adicionar createCsrfMiddleware() por conta própria.

### Como valido FormData numa server function do TanStack Start?

Use .validator() para conferir que a entrada é FormData e transformá-la num objeto simples, e depois rode o safeParse do Zod no handler para poder devolver uma mensagem por campo.

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