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.
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.
- O visitante chega por um link de campanha com tags UTM ou por um link de outro site.
- proxy.ts guarda as tags, o referrer e o caminho de entrada num cookie httpOnly, uma única vez.
- As páginas de preços e de contato continuam estáticas e saem do cache.
- No envio, a server action valida os campos com Zod e lê o cookie.
- Ela envia o lead, a atribuição e um fit calculado ao Ikiform com a chave secreta.
- Um lead qualificado é redirecionado ao agendador com nome e e-mail preenchidos; os outros vão para a página de agradecimento.
- Um erro de validação volta como estado da action, com todos os valores que o visitante digitou.
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.
app/
contact-sales/
page.tsx
actions.ts
lead-form.tsx
thanks/page.tsx
lib/
lead.ts
proxy.ts
.env.localIKIFORM_SECRET_KEY=ik_sk_your_secret_keyDepois 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 |
IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor.Atribuição de primeiro toque em 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
hasse 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.
URLSearchParamsdá 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.
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;
}"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.flattenErrortransforma os problemas dele em uma mensagem por campo. - O navegador manda um header
Originem 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 emlocalhost. 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 blocotry.redirect()funciona lançando uma exceção, e umcatcha 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
"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>
);
}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:
| 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.
- Rode
next deve abra/pricing?utm_source=test&utm_campaign=launch. Confira o cookielead_sourcenas ferramentas de desenvolvedor do navegador. - Navegue até
/contact-sales, deixe Company size vazio e envie. O erro aparece abaixo do campo e os outros valores continuam lá. - Envie como lead qualificado: 51 a 200 pessoas, $10k a $50k, este trimestre. Você cai no Cal.com com nome e e-mail preenchidos.
- Envie como uma equipe de 1 a 10 pessoas. Você cai em
/contact-sales/thanks. - Abra Submissions no painel. Os dois leads estão lá com
utm_source,landing_pageefit. - 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
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.
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.
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.
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.