# Crie um formulário de leads com qualificação no Next.js

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

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

Capture as tags de campanha e o referrer em `proxy.ts` na primeira visita e guarde-os num cookie. Valide o lead com Zod numa server action, pontue pelo tamanho da empresa, orçamento e prazo, e encaminhe-o com a atribuição para o seu backend de formulários. Depois chame `redirect()` para mandar leads qualificados ao seu agendador e todos os outros para uma página de agradecimento.

A [aba Next.js do guia principal](https://ikiform.com/pt-br/guides/lead-capture.md#form) encaminha um lead e mostra uma frase de agradecimento. Esta página monta a versão de vendas: três perguntas de qualificação conferidas no seu servidor, dados de campanha que sobrevivem enquanto o visitante navega antes de converter, e uma passagem direta para a sua agenda para os leads que valem uma call.

## O que muda no Next.js

Dois recursos do Next.js fazem a maior parte do trabalho. `proxy.ts`, o arquivo que se chamava `middleware.ts` antes do Next.js 16, roda antes de toda requisição de página, mesmo de páginas estáticas. Isso o torna o lugar certo para ler `?utm_source=` e o header `Referer` na primeira visita, porque quando o visitante chega em **Talk to sales** os dois já sumiram da URL. A server action então lê o cookie, e a própria página de contato pode continuar estática.

O segundo é `redirect()`. Numa server action ele aceita uma URL absoluta, então um lead qualificado cai no Cal.com sem nenhum código no cliente. Com o JavaScript carregado, o Next.js navega no cliente; antes da hidratação, ele responde ao envio do formulário com um `303`.

Diagrama: Como um lead em Next.js vai do primeiro clique no anúncio até uma call agendada

1. O visitante chega por um link de campanha com tags UTM ou por um link de outro site.
2. proxy.ts guarda as tags, o referrer e o caminho de entrada num cookie httpOnly, uma única vez.
3. As páginas de preços e de contato continuam estáticas e saem do cache.
4. No envio, a server action valida os campos com Zod e lê o cookie.
5. Ela envia o lead, a atribuição e um fit calculado ao Ikiform com a chave secreta.
6. Um lead qualificado é redirecionado ao agendador com nome e e-mail preenchidos; os outros vão para a página de agradecimento.
7. Um erro de validação volta como estado da action, com todos os valores que o visitante digitou.

O cookie leva o primeiro toque entre as páginas, então a página de contato nunca precisa ler a URL.

## Configuração do projeto

Comece de qualquer projeto App Router no Next.js 16. Instale `zod`, crie um formulário no painel e uma chave secreta com o escopo `submissions:write` em **API keys**. A [página da documentação sobre Next.js](https://ikiform.com/pt-br/docs/guides/nextjs.md) cobre a chave e a configuração do formulário em detalhes.

`arquivos`:

```text
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
```

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

Depois adicione os campos na aba **Fields** do formulário, para que o Ikiform também os imponha. Sem uma definição, o Ikiform armazena o que você enviar; com uma, um campo desconhecido gera um `422`, que é o que você quer quando a atribuição está no payload.

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

## Atribuição de primeiro toque em proxy.ts

`proxy.ts`:

```ts
import { type NextRequest, NextResponse } from "next/server";

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

export function proxy(request: NextRequest) {
  const response = NextResponse.next();
  if (request.cookies.has("lead_source")) return response;

  const url = request.nextUrl;
  const referer = request.headers.get("referer") ?? "";
  const from = URL.canParse(referer) ? new URL(referer) : null;
  const external = from && from.host !== url.host ? from.href : "";
  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))) return response;

  source.set("referrer", external.slice(0, 300));
  source.set("landing_page", url.pathname.slice(0, 200));
  response.cookies.set("lead_source", source.toString(), {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
  return response;
}

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};
```

- O cookie é definido uma vez e nunca sobrescrito, então registra a primeira campanha, não o último clique interno. Remova a verificação `has` se preferir o último toque.
- Um referrer do seu próprio host é ignorado. Sem isso, toda visualização de página depois da primeira contaria como uma indicação vinda de você mesmo.
- O matcher pula arquivos estáticos e prefetches do router. Os prefetches trazem a sua própria página como referrer e, sem isso, rodariam o proxy para cada link na tela.
- `URLSearchParams` dá um valor de cookie compacto e um parser seguro. A action só lê as cinco chaves que conhece, então um visitante que edite o cookie não consegue adicionar campos.

## Validação, pontuação e a passagem

O schema fica no seu próprio módulo, para que a action e qualquer rota de API futura o compartilhem. A regra de fit é propositalmente simples: pelo menos onze pessoas, orçamento de $10k ou mais e início dentro de dois trimestres.

`lib/lead.ts`:

```ts
import { z } from "zod";

export 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(),
});

export type Lead = z.infer<typeof LeadSchema>;

export function isQualified(lead: Lead) {
  const bigEnough = lead.company_size !== "1_10";
  const funded = lead.budget === "10k_50k" || lead.budget === "50k_plus";
  const soon = lead.timeline === "this_quarter" || lead.timeline === "next_quarter";
  return bigEnough && funded && soon;
}
```

`app/contact-sales/actions.ts`:

```ts
"use server";

import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { isQualified, LeadSchema } from "@/lib/lead";

export interface LeadState {
  errors: Record<string, string>;
  values: Record<string, string>;
}

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export async function submitLead(_previous: LeadState, formData: FormData): Promise<LeadState> {
  const values = Object.fromEntries([...formData].map(([key, value]) => [key, String(value)]));
  const parsed = LeadSchema.safeParse(values);
  if (!parsed.success) {
    const { fieldErrors } = z.flattenError(parsed.error);
    const errors = Object.fromEntries(Object.entries(fieldErrors).map(([key, list]) => [key, list?.[0] ?? ""]));
    return { errors, values };
  }

  const stored = new URLSearchParams((await cookies()).get("lead_source")?.value ?? "");
  const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
  const origin = (await headers()).get("origin");
  const qualified = isQualified(parsed.data);

  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({
      ...parsed.data,
      ...source,
      fit: qualified ? "qualified" : "nurture",
      _gotcha: values._gotcha ?? "",
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);
  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] ?? ""]));
    if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
    return { errors, values };
  }

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

- O Zod roda antes de qualquer coisa sair do seu servidor, e `z.flattenError` transforma os problemas dele em uma mensagem por campo.
- O navegador manda um header `Origin` em todo envio de server action, e a action o repassa: um fetch no servidor não manda nenhum, e o Ikiform detecta por ele os testes gratuitos em `localhost`.
- `fit` é calculado no servidor. Um visitante não consegue se marcar como qualificado editando o formulário.
- As duas chamadas a `redirect()` ficam fora de qualquer bloco `try`. `redirect()` funciona lançando uma exceção, e um `catch` a engoliria.
- Um honeypot preenchido ainda é enviado, então o Ikiform o registra como spam, mas ele nunca chega à sua agenda.

## O formulário, com e sem JavaScript

`app/contact-sales/lead-form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type LeadState, submitLead } from "./actions";

const initial: LeadState = { errors: {}, values: {} };

function FieldError({ message }: { message?: string }) {
  return message ? <p role="alert">{message}</p> : null;
}

export function LeadForm() {
  const [state, action, pending] = useActionState(submitLead, initial);
  const { errors, values } = state;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required defaultValue={values.name} />
      <FieldError message={errors.name} />

      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      <FieldError message={errors.email} />

      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" required defaultValue={values.company} />
      <FieldError message={errors.company} />

      <label htmlFor="company_size">Company size</label>
      <select id="company_size" name="company_size" required defaultValue={values.company_size ?? ""} key={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>
      <FieldError message={errors.company_size} />

      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue={values.budget ?? ""} key={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>
      <FieldError message={errors.budget} />

      <label htmlFor="timeline">When do you want to start?</label>
      <select id="timeline" name="timeline" required defaultValue={values.timeline ?? ""} key={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>
      <FieldError message={errors.timeline} />

      <label htmlFor="message">Anything we should know?</label>
      <textarea id="message" name="message" rows={4} defaultValue={values.message} />

      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <FieldError message={errors.form} />
      <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
    </form>
  );
}
```

`app/contact-sales/page.tsx`:

```tsx
import { LeadForm } from "./lead-form";

export default function ContactSales() {
  return (
    <main>
      <h1>Talk to sales</h1>
      <p>Tell us a little about your team. If we look like a fit, you pick a call time on the next screen.</p>
      <LeadForm />
    </main>
  );
}
```

`useActionState` dá ao formulário o indicador de pendente e os erros devolvidos. Como a action é uma server function, o formulário também envia antes da hidratação: o Next.js renderiza o resultado no servidor e o visitante vê os mesmos erros, só que com um carregamento de página completo.

O React reseta o formulário depois que a action roda, então todo caminho de erro devolve `values` e cada input o lê de volta como `defaultValue`. A `key` em cada `select` força a remontagem com a opção devolvida; um `select` não pega sozinho uma mudança no `defaultValue`.

### Honeypot e verificação humana

O honeypot oculto é encaminhado como está. A verificação humana do próprio Ikiform não roda num envio com chave secreta, então, se este formulário atrair tráfego de bots, valide um token do Turnstile na action antes de enviar, como em [Turnstile num formulário](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md).

## Renderização e deploy

Nada na página de contato lê dados da requisição, então `next build` a pré-renderiza. O proxy e a action rodam a cada requisição. Confira como o seu host os executa:

| Host | O que saber |
| --- | --- |
| Vercel | Proxy e server actions funcionam sem configuração. Adicione `IKIFORM_SECRET_KEY` nas variáveis de ambiente do projeto para Production e Preview. |
| Netlify | O Next.js roda pelo adapter da Netlify sem configuração. Defina a chave nas variáveis de ambiente do site, com escopo para functions. |
| Cloudflare | Faça o deploy com o adapter OpenNext para Cloudflare e guarde a chave com `wrangler secret put`. Confira a documentação dele sobre o suporte atual a proxy antes de depender do cookie. |

## Confira se funciona

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

1. Rode `next dev` e abra `/pricing?utm_source=test&utm_campaign=launch`. Confira o cookie `lead_source` nas ferramentas de desenvolvedor do navegador.
2. Navegue até `/contact-sales`, deixe **Company size** vazio e envie. O erro aparece abaixo do campo e os outros valores continuam lá.
3. Envie como lead qualificado: 51 a 200 pessoas, $10k a $50k, este trimestre. Você cai no 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. Os dois leads estão lá com `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 |
| --- | --- |
| Os campos UTM chegam vazios | O cookie foi definido antes de você adicionar as tags, ou o arquivo do proxy não está na raiz do projeto (ou em `src/`, se você usa essa pasta). Apague o cookie e recarregue com as tags. |
| O redirecionamento nunca acontece e o formulário não mostra nada | `redirect()` foi chamado dentro de `try`. Mova-o para depois do bloco, como no exemplo. |
| `422` com `Unknown field` para `fit` ou `referrer` | A definição de campos do formulário não o lista. Adicione cada id da tabela como campo opcional. |
| `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. |
| Os selects voltam para **Choose one** depois de um erro | Falta a prop `key`, então o React mantém o `select` resetado. Use como key de cada um o valor devolvido. |

## Perguntas

### Uma server action do Next.js pode redirecionar para uma URL externa?

Sim. redirect() de next/navigation aceita uma URL absoluta. Numa server action, ele navega no cliente quando o JavaScript está carregado e responde com um 303 para um formulário enviado antes da hidratação. Chame-o fora de qualquer bloco try, porque ele funciona lançando uma exceção.

### Por que capturar os parâmetros UTM em proxy.ts e não na página de contato?

Visitantes raramente convertem na página em que chegam. O proxy roda na primeira requisição, guarda as tags e o referrer num cookie, e a server action o lê depois. A página de contato continua estática porque nunca lê a URL.

### Preciso de um banco de dados para qualificar leads no Next.js?

Não. A server action pontua o lead, encaminha-o ao Ikiform com o resultado num campo fit e redireciona. O Ikiform armazena o lead, avisa a sua equipe por e-mail e pode enviar um webhook para o seu CRM.

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