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

> Um formulário de leads em Nuxt: um server middleware registra as UTMs de primeiro toque, uma server route validada com Zod atende fetch e envios simples, e os bons leads agendam uma call.

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

Registre as tags UTM e o referrer num server middleware do Nitro na primeira requisição de página, num cookie httpOnly. Envie o formulário a uma única server route que valida com Zod, encaminha o lead com a atribuição para o seu backend de formulários e responde JSON ao `$fetch` ou um `303` a um envio de formulário simples. No cliente, `navigateTo(url, { external: true })` manda leads qualificados ao seu agendador.

A [aba Nuxt do guia principal](https://ikiform.com/pt-br/guides/lead-capture.md#form) envia um lead por uma server route com `$fetch` e mostra uma frase de agradecimento. Esta página monta a versão de vendas: tamanho da empresa, orçamento e prazo conferidos pelo servidor, a campanha por trás de cada lead, uma passagem para a sua agenda e um caminho que continua funcionando quando o JavaScript não carrega.

## O que muda no Nuxt

O Nuxt não tem form actions. Um formulário envia a uma server route do Nitro como a qualquer outro endpoint, o que deixa dois transportes para atender: `$fetch` com JSON a partir da página hidratada, e um envio URL-encoded a partir do formulário HTML puro. O `readBody` do h3 interpreta os dois, então uma rota pode atender ambos, desde que responda a cada um do seu jeito.

Essa conveniência tem um custo: uma rota que aceita corpos URL-encoded também aceita envios de formulário simples vindos de outros sites. A documentação do h3 alerta sobre isso e, ao contrário do SvelteKit ou do Next.js, o Nuxt não adiciona nenhuma verificação de origem por você. A própria rota confere o `Origin`.

Diagrama: Uma server route do Nuxt respondendo a um formulário hidratado e a um envio de formulário simples

1. Com JavaScript, a página envia o objeto reativo do lead como JSON com $fetch.
2. A server route rejeita envios de outras origens, valida com Zod e encaminha o lead com a atribuição.
3. Ela responde JSON com a próxima URL, e navigateTo segue para o Cal.com ou para a página de agradecimento.
4. Uma falha de validação é lançada com createError, e a página lê as mensagens de campo nos dados do erro.
5. Sem JavaScript, o navegador envia o formulário URL-encoded para a mesma rota.
6. A rota roda as mesmas verificações e responde com um 303: para o agendador, para a página de agradecimento, ou de volta ao formulário com os nomes dos campos que falharam.

Quem decide a resposta é o content type, não a rota. A validação e o envio ao Ikiform são escritos uma vez só.

## Configuração do projeto

Comece de um app Nuxt 4 e instale `zod`. Declare a chave em `runtimeConfig` e defina-a como `NUXT_IKIFORM_SECRET_KEY`; o Nuxt mapeia a variável em tempo de execução quando a rota lê `useRuntimeConfig(event)`. A [página da documentação sobre Nuxt](https://ikiform.com/pt-br/docs/guides/nuxt.md) cobre a chave e o formulário.

`arquivos`:

```text
app/
  pages/
    contact-sales.vue
    contact-sales/thanks.vue
server/
  middleware/first-touch.ts
  api/lead.post.ts
  utils/lead.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

Adicione os campos na aba **Fields** do formulário, para que o Ikiform repita a validação e recuse chaves desconhecidas.

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

## Server middleware para o primeiro toque

`server/middleware/first-touch.ts`:

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

export default defineEventHandler((event) => {
  if (event.method !== "GET" || getCookie(event, "lead_source")) return;
  if (!getRequestHeader(event, "accept")?.includes("text/html")) return;

  const url = getRequestURL(event);
  const referer = getRequestHeader(event, "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))) return;

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

- O Nitro roda os arquivos de `server/middleware` antes de toda requisição e espera que eles não devolvam nada. Devolver um valor encerraria a requisição.
- A verificação de `accept` limita o cookie a carregamentos de página, então chamadas de API e assets o pulam.
- `getCookie`, `setCookie` e os outros helpers do h3 são importados automaticamente em `server/`.
- Páginas pré-renderizadas com `routeRules` ou `nuxt generate` são servidas como arquivos, então o middleware nunca vê essas visitas. Mantenha as landing pages de campanha renderizadas no servidor.

## Uma rota, duas respostas

`server/utils/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(),
  _gotcha: z.string().optional(),
});

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

export const isQualified = (lead: Lead) =>
  lead.company_size !== "1_10" &&
  (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
  (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");
```

`server/api/lead.post.ts`:

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

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

export default defineEventHandler(async (event) => {
  const origin = getRequestHeader(event, "origin");
  if (origin && new URL(origin).host !== getRequestHost(event)) {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const wantsJson = getRequestHeader(event, "content-type")?.includes("application/json");
  const parsed = LeadSchema.safeParse(await readBody(event));

  if (!parsed.success) {
    const fields = z.flattenError(parsed.error).fieldErrors;
    if (!wantsJson) return sendRedirect(event, `/contact-sales?invalid=${Object.keys(fields).join(",")}`, 303);
    throw createError({ status: 400, message: "Check the highlighted fields.", data: { fields } });
  }

  const lead = parsed.data;
  const stored = new URLSearchParams(getCookie(event, "lead_source") ?? "");
  const qualified = isQualified(lead);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(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) {
    if (!wantsJson) return sendRedirect(event, "/contact-sales?failed=1", 303);
    throw createError({
      status: response.status,
      message: result?.error?.message ?? "We couldn't send that. Try again.",
      data: { fields: result?.error?.fields ?? {} },
    });
  }

  const next =
    qualified && !lead._gotcha
      ? `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`
      : "/contact-sales/thanks";
  return wantsJson ? { next } : sendRedirect(event, next, 303);
});
```

- Os exports de `server/utils` são importados automaticamente nas server routes, e é por isso que `LeadSchema` e `isQualified` não precisam de import.
- O mesmo header `Origin` do navegador faz dois trabalhos: bloqueia envios de outros sites e é repassado ao Ikiform, porque um fetch no servidor não manda nenhum e o Ikiform detecta por ele os testes gratuitos em `localhost`.
- `createError` coloca as mensagens de campo em `data`, e a resposta de erro do Nuxt as aninha, então o cliente lê `error.data.data.fields`.
- Um envio simples não consegue levar estado de volta sem uma sessão, então uma falha redireciona com os nomes dos campos que falharam na query string. O visitante digita de novo; é o caminho de reserva.

## A página

`app/pages/contact-sales.vue`:

```vue
<script setup lang="ts">
const route = useRoute();
const lead = reactive({ name: "", email: "", company: "", company_size: "", budget: "", timeline: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[] | undefined>>({});
const failure = ref(route.query.failed ? "We couldn't send that. Email sales@example.com instead." : "");
const sending = ref(false);

for (const key of String(route.query.invalid ?? "").split(",").filter(Boolean)) {
  errors.value[key] = ["Check this field."];
}

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;

async function send() {
  sending.value = true;
  errors.value = {};
  failure.value = "";
  try {
    const { next } = await $fetch<{ next: string }>("/api/lead", { method: "POST", body: lead });
    await navigateTo(next, { external: next.startsWith("https://") });
  } catch (error) {
    const data = (error as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
    errors.value = data?.data?.fields ?? {};
    if (!Object.keys(errors.value).length) failure.value = data?.message ?? "We couldn't send that. Try again.";
    sending.value = false;
  }
}
</script>

<template>
  <form action="/api/lead" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="lead.name" name="name" autocomplete="name" required />
    <p v-if="errors.name" role="alert">{{ errors.name[0] }}</p>
    <label for="email">Work email</label>
    <input id="email" v-model="lead.email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
    <label for="company">Company</label>
    <input id="company" v-model="lead.company" name="company" autocomplete="organization" required />
    <p v-if="errors.company" role="alert">{{ errors.company[0] }}</p>
    <template v-for="([label, options], id) in selects" :key="id">
      <label :for="id">{{ label }}</label>
      <select :id="id" v-model="lead[id]" :name="id" required>
        <option value="" disabled>Choose one</option>
        <option v-for="(text, value) in options" :key="value" :value="value">{{ text }}</option>
      </select>
      <p v-if="errors[id]" role="alert">{{ errors[id]?.[0] }}</p>
    </template>
    <label for="message">Anything we should know?</label>
    <textarea id="message" v-model="lead.message" name="message" rows="4" />
    <input v-model="lead._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="failure" role="alert">{{ failure }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Talk to sales" }}</button>
  </form>
</template>
```

`@submit.prevent` assume quando o Nuxt já hidratou, e o objeto reativo `lead` mantém todos os valores durante um erro. `navigateTo` recusa uma URL absoluta a menos que `external` esteja definido, então a flag acompanha a URL: externa para o Cal.com, navegação no cliente para a página de agradecimento. Antes da hidratação, o atributo `action` envia à mesma rota e a página lê `?invalid=` para marcar os campos.

### Honeypot e verificação humana

O honeypot está no schema e é encaminhado, então o Ikiform armazena um preenchido como spam, e a rota o manda para a página de agradecimento. A chave secreta pula a verificação humana do Ikiform; valide um token do Turnstile na rota se precisar, como em [Turnstile num formulário](https://ikiform.com/pt-br/guides/spam-protection/cloudflare-turnstile.md).

## Notas de deploy

| Host | O que saber |
| --- | --- |
| Vercel | O Nitro detecta a Vercel e gera functions. Defina `NUXT_IKIFORM_SECRET_KEY` nas variáveis de ambiente. |
| Netlify | O Nitro também detecta a Netlify. Defina a variável nas configurações do site. |
| Cloudflare | Faça o build com o preset `cloudflare_module` e guarde a variável com `wrangler secret put`. Ler `useRuntimeConfig(event)` com o event a pega em tempo de execução. |

## Confira se funciona

Envios do `nuxt 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 `nuxt dev`, abra `/?utm_source=test&utm_campaign=launch` e confira o cookie `lead_source` nas ferramentas de desenvolvedor.
2. Em `/contact-sales`, deixe **Timeline** vazio e envie. O erro aparece e as outras respostas continuam.
3. Desligue o JavaScript e envie do mesmo jeito. Você volta para `/contact-sales?invalid=timeline` com o campo marcado.
4. Envie como lead qualificado: 11 a 50 pessoas, mais de $50k, este 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 |
| --- | --- |
| `navigateTo` lança um erro quando um lead se qualifica | Falta `external: true`. `navigateTo` recusa URLs absolutas para outros sites sem isso. |
| A chave está vazia em produção | A variável precisa se chamar `NUXT_IKIFORM_SECRET_KEY`, e a rota precisa chamar `useRuntimeConfig(event)` para que ela seja lida em tempo de execução. |
| `403 Cross-site post` atrás de um proxy | O proxy muda o `Host` que a rota vê. Repasse o host original, ou compare a origem com o seu domínio público. |
| O cookie é definido em chamadas de API ou imagens | A verificação de `accept` foi removida. Limite o middleware a carregamentos de página. |
| `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 no Nuxt?

No cliente, chame navigateTo(url, { external: true }); sem a flag, ele lança um erro para outro site. Numa server route, sendRedirect(event, url, 303) responde a um envio de formulário simples com um redirecionamento.

### Uma única server route do Nuxt pode tratar tanto $fetch quanto um envio de formulário simples?

Sim. readBody interpreta corpos JSON e URL-encoded, então confira o content type e responda JSON ao $fetch e um redirect 303 a um envio nativo. Confira também o header Origin, porque agora a rota aceita envios de formulário vindos de outros sites.

### Onde deve ficar o rastreamento de UTM no Nuxt?

Num arquivo em server/middleware. Ele roda antes de toda requisição ao servidor, consegue ler a query string e o referrer e define um cookie httpOnly que a rota do lead lê depois. Ele não roda para páginas pré-renderizadas servidas como arquivos.

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