Formulário de captação de leads

Formulário de captação de leads9 min de leitura

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.

Preet SutharFundador, Ikiform

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

Como um lead em Next.js vai do primeiro clique no anúncio até uma call agendadaprimeira visitaenviaranúncio ou link?utm_source=...proxy.tsdefine o cookielead_sourcepágina estáticaservida do cacheserver actionZod, lê o cookieIkiformlead com UTMs e fitredirect()Cal.com,pré-preenchidoaction stateerros e valoresdigitadosGETnext()JSONok422 ou erro do ZodComo um lead em Next.js vai do primeiro clique no anúncio até uma call agendadaprimeira visitaenviaranúncio ou link?utm_source=...proxy.tsdefine o cookielead_sourcepágina estáticaservida do cacheserver actionZod, lê o cookieIkiformlead com UTMs e fitredirect()Cal.com,pré-preenchidoactionstateerros evaloresdigitadosGETnext()JSONok422 ou erro do Zod
  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 cobre a chave e a configuração do formulário em detalhes.

arquivos
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
.env.local
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 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.

Atribuição de primeiro toque em proxy.ts

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

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:

HostO que saber
VercelProxy e server actions funcionam sem configuração. Adicione IKIFORM_SECRET_KEY nas variáveis de ambiente do projeto para Production e Preview.
NetlifyO Next.js roda pelo adapter da Netlify sem configuração. Defina a chave nas variáveis de ambiente do site, com escopo para functions.
CloudflareFaç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.

  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

ProblemaO que fazer
Os campos UTM chegam vaziosO 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 nadaredirect() foi chamado dentro de try. Mova-o para depois do bloco, como no exemplo.
422 com Unknown field para fit ou referrerA definição de campos do formulário não o lista. Adicione cada id da tabela como campo opcional.
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.
Os selects voltam para Choose one depois de um erroFalta a prop key, então o React mantém o select resetado. Use como key de cada um o valor devolvido.

Perguntas

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