# Crea un formulario de leads con cualificación en Nuxt

> Un formulario de leads en Nuxt: un middleware de servidor registra las UTM del primer contacto, una server route validada con Zod atiende fetch y envíos normales, y los buenos leads reservan una llamada.

Publicado el 2026-10-25 · https://ikiform.com/es/guides/lead-capture/nuxt

Registra las etiquetas UTM y el referrer en un middleware de servidor de Nitro en la primera petición de página, en una cookie httpOnly. Envía el formulario a una única server route que valide con Zod, reenvíe el lead con la atribución a tu backend de formularios y responda con JSON a `$fetch` o con un `303` a un envío de formulario normal. En el cliente, `navigateTo(url, { external: true })` manda a los leads cualificados a tu agenda.

La [pestaña de Nuxt de la guía principal](https://ikiform.com/es/guides/lead-capture.md#form) envía un lead mediante una server route con `$fetch` y muestra una línea de agradecimiento. Esta página construye la versión de ventas: tamaño de empresa, presupuesto y plazos comprobados por el servidor, la campaña detrás de cada lead, el paso a tu calendario y un camino que sigue funcionando cuando JavaScript no carga.

## Qué cambia con Nuxt

Nuxt no tiene form actions. Un formulario envía a una server route de Nitro como a cualquier otro endpoint, lo que te deja dos transportes que atender: `$fetch` con JSON desde la página hidratada, y un envío URL-encoded desde el formulario HTML sin más. `readBody` de h3 analiza ambos, así que una ruta puede atender los dos, siempre que responda a cada uno a su manera.

Esa comodidad tiene un coste: una ruta que acepta cuerpos URL-encoded también acepta envíos de formulario normales desde otros sitios. La documentación de h3 lo advierte y, a diferencia de SvelteKit o Next.js, Nuxt no añade ninguna comprobación de origen por ti. La ruta comprueba el `Origin` por sí misma.

Diagrama: Una server route de Nuxt respondiendo a un formulario hidratado y a un envío de formulario normal

1. Con JavaScript, la página envía el objeto reactivo del lead como JSON con $fetch.
2. La server route rechaza los envíos desde otros orígenes, valida con Zod y reenvía el lead con su atribución.
3. Responde con JSON con la siguiente URL, y navigateTo se va a Cal.com o a la página de agradecimiento.
4. Un fallo de validación se lanza con createError, y la página lee los mensajes de los campos de los datos del error.
5. Sin JavaScript, el navegador envía el formulario URL-encoded a la misma ruta.
6. La ruta hace las mismas comprobaciones y responde con un 303: a la agenda, a la página de agradecimiento o de vuelta al formulario con los nombres de los campos que fallan.

El content type decide la respuesta, no la ruta. La validación y el envío a Ikiform se escriben una sola vez.

## Configuración del proyecto

Parte de una app de Nuxt 4 e instala `zod`. Declara la clave en `runtimeConfig` y defínela como `NUXT_IKIFORM_SECRET_KEY`; Nuxt asigna la variable en tiempo de ejecución cuando la ruta lee `useRuntimeConfig(event)`. La [página de la documentación de Nuxt](https://ikiform.com/es/docs/guides/nuxt.md) cubre la clave y el formulario.

`archivos`:

```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: "",
  },
});
```

Añade los campos en la pestaña **Fields** del formulario, para que Ikiform repita la validación y rechace las claves desconocidas.

| Id del campo | Tipo en Ikiform | Lo pone |
| --- | --- | --- |
| `name`, `email`, `company` | Texto, email, texto | El visitante |
| `company_size` | Selección: `1_10`, `11_50`, `51_200`, `201_1000`, `1000_plus` | El visitante |
| `budget` | Selección: `under_10k`, `10k_50k`, `50k_plus`, `unsure` | El visitante |
| `timeline` | Selección: `this_quarter`, `next_quarter`, `later`, `researching` | El visitante |
| `message` | Textarea, opcional | El visitante |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | Texto, opcional | Tu servidor, desde la primera visita |
| `fit` | Selección: `qualified`, `nurture` | Tu servidor, tras la validación |

> Las versiones de servidor envían con una clave secreta, así que Ikiform se salta **Allowed domains** (dominios permitidos) y su verificación humana. Todos los envíos vienen entonces de la IP de tu servidor, y el límite Standard de 5 por minuto por visitante cuenta a todos tus visitantes juntos. Elige el [límite de frecuencia](https://ikiform.com/es/docs/rate-limits.md) Relaxed o Custom para un formulario que envías a través de un servidor, y guarda `IKIFORM_SECRET_KEY` en variables de entorno solo de servidor.

## Middleware de servidor para el primer contacto

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

- Nitro ejecuta los archivos de `server/middleware` antes de cada petición y espera que no devuelvan nada. Devolver un valor terminaría la petición.
- La comprobación de `accept` limita la cookie a las cargas de página, así que las llamadas a la API y los assets se la saltan.
- `getCookie`, `setCookie` y los demás helpers de h3 se importan automáticamente en `server/`.
- Las páginas prerenderizadas con `routeRules` o `nuxt generate` se sirven como archivos, así que el middleware nunca ve esas visitas. Mantén las landing pages de campaña renderizadas en el servidor.

## Una ruta, dos respuestas

`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);
});
```

- Los exports de `server/utils` se importan automáticamente en las server routes, por eso `LeadSchema` e `isQualified` no necesitan import.
- La misma cabecera `Origin` del navegador hace dos trabajos: bloquea los envíos desde otros sitios y se reenvía a Ikiform, porque un fetch desde el servidor no envía ninguna e Ikiform detecta a partir de ella las pruebas gratuitas desde `localhost`.
- `createError` pone los mensajes de los campos en `data`, y la respuesta de error de Nuxt los anida, así que el cliente lee `error.data.data.fields`.
- Un envío normal no puede devolver estado sin una sesión, así que un fallo redirige con los nombres de los campos que fallan en la query string. El visitante vuelve a escribir; es el camino alternativo.

## La 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` toma el control cuando Nuxt se ha hidratado, y el objeto reactivo `lead` conserva todos los valores tras un error. `navigateTo` rechaza una URL absoluta a menos que se ponga `external`, así que el flag depende de la URL: externa para Cal.com, navegación de cliente para la página de agradecimiento. Antes de la hidratación, el atributo `action` envía a la misma ruta y la página lee `?invalid=` para marcar los campos.

### Honeypot y verificación humana

El honeypot está en el esquema y se reenvía, así que Ikiform guarda como spam uno relleno, y la ruta lo manda a la página de agradecimiento. La clave secreta se salta la verificación humana de Ikiform; verifica un token de Turnstile en la ruta si lo necesitas, como en [Turnstile en un formulario](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md).

## Notas de despliegue

| Host | Qué debes saber |
| --- | --- |
| Vercel | Nitro detecta Vercel y genera funciones. Define `NUXT_IKIFORM_SECRET_KEY` en las variables de entorno. |
| Netlify | Nitro también detecta Netlify. Define la variable en los ajustes del sitio. |
| Cloudflare | Haz el build con el preset `cloudflare_module` y guarda la variable con `wrangler secret put`. Leer `useRuntimeConfig(event)` con el event la recoge en tiempo de ejecución. |

## Comprueba que funciona

Los envíos desde `nuxt dev` llevan un origen `localhost`. Ikiform no tiene plan gratuito. Probar es gratis: un espacio de trabajo sin plan acepta 50 envíos de prueba al mes desde `localhost`. Para salir a producción, elige Pro desde 6 $/mes para 500 envíos, o paga una vez: 10.000 envíos por 49 $ o 50.000 envíos por 149 $, con créditos que nunca caducan. Consulta los [precios](https://ikiform.com/es/pricing).

1. Ejecuta `nuxt dev`, abre `/?utm_source=test&utm_campaign=launch` y comprueba la cookie `lead_source` en las herramientas de desarrollo.
2. En `/contact-sales`, deja **Timeline** vacío y envía. Aparece el error y las demás respuestas se mantienen.
3. Desactiva JavaScript y envía de la misma forma. Vuelves a `/contact-sales?invalid=timeline` con el campo marcado.
4. Envía como lead cualificado: de 11 a 50 personas, más de 50k $, este trimestre. Llegas a Cal.com con el nombre y el email rellenos.
5. Abre **Submissions** en el panel y comprueba `utm_campaign`, `referrer` y `fit`.
6. Revisa la bandeja de entrada de cada destinatario de las notificaciones. Cada uno recibe un email con los campos, y **Reply** va a quien envió el formulario cuando este tiene un campo `email`.

## Errores comunes

| Problema | Qué hacer |
| --- | --- |
| `navigateTo` lanza un error cuando un lead se cualifica | Falta `external: true`. `navigateTo` rechaza sin él las URLs absolutas a otros sitios. |
| La clave está vacía en producción | La variable debe llamarse `NUXT_IKIFORM_SECRET_KEY`, y la ruta debe llamar a `useRuntimeConfig(event)` para que se lea en tiempo de ejecución. |
| `403 Cross-site post` detrás de un proxy | El proxy cambia el `Host` que ve la ruta. Reenvía el host original, o compara el origen con tu dominio público. |
| La cookie se pone en llamadas a la API o en imágenes | Se quitó la comprobación de `accept`. Limita el middleware a las cargas de página. |
| `422 validation_failed` | El envío no coincide con la definición de campos del formulario. `error.fields` indica cada campo y el motivo. Haz que el `name` del input coincida con el id del campo. |

## Preguntas

### ¿Cómo redirijo a una URL externa en Nuxt?

En el cliente, llama a navigateTo(url, { external: true }); sin el flag lanza un error con otro sitio. En una server route, sendRedirect(event, url, 303) responde con una redirección a un envío de formulario normal.

### ¿Puede una sola server route de Nuxt atender $fetch y un envío de formulario normal?

Sí. readBody analiza cuerpos JSON y URL-encoded, así que comprueba el content type y responde con JSON a $fetch y con una redirección 303 a un envío nativo. Comprueba también la cabecera Origin, porque ahora la ruta acepta envíos de formulario desde otros sitios.

### ¿Dónde debe ir el seguimiento de UTM en Nuxt?

En un archivo dentro de server/middleware. Se ejecuta antes de cada petición al servidor, puede leer la query string y el referrer, y pone una cookie httpOnly que la ruta del lead lee después. No se ejecuta para las páginas prerenderizadas servidas como archivos.

### ¿Ikiform es gratis?

No, no hay plan gratuito. Probar es gratis: puedes enviar 50 envíos de prueba al mes desde localhost antes de pagar. Para pasar a producción, Pro empieza en 6 $/mes por 500 envíos, o puedes pagar una sola vez 49 $ por 10.000 envíos o 149 $ por 50.000 envíos, con créditos que nunca caducan.

## Más guías

- [Formulario de captación de leads](https://ikiform.com/es/guides/lead-capture.md): Un formulario de captación de leads en tu propio sitio que te avisa por email con reply-to, avisa al chat del equipo y alimenta tu CRM, para responder mientras el lead sigue ahí.
- [Formulario de leads en SvelteKit](https://ikiform.com/es/guides/lead-capture/sveltekit.md): Un formulario de leads en SvelteKit: el hook handle registra las UTM del primer contacto, una form action valida con Valibot y redirect() manda a los leads cualificados a un calendario.
- [Formulario de leads en Next.js](https://ikiform.com/es/guides/lead-capture/nextjs.md): Un formulario de leads en el App Router de Next.js que valida con Zod en una server action, guarda los UTMs del primer contacto en una cookie y envía los leads cualificados a tu calendario.
- [Formulario de solicitud de demo](https://ikiform.com/es/guides/lead-capture/demo-request.md): Un formulario para reservar una demo que guarda el lead y luego envía al visitante a Cal.com o Calendly con su nombre y email ya rellenados.
- [Formulario de solicitud de presupuesto](https://ikiform.com/es/guides/lead-capture/quote-request.md): Un formulario de solicitud de presupuesto que recoge los briefs como enlaces compartidos en lugar de archivos, pone fecha límite a cada solicitud y evita que los leads con muchos enlaces acaben en spam.
- [Formulario de solicitud de consulta](https://ikiform.com/es/guides/lead-capture/consultation-request.md): Un formulario de solicitud de consulta para gestorías, abogados, coaches y clínicas que pregunta cómo y cuándo llamar, y deja los detalles sensibles para la llamada.
