Formulario de captación de leads

Formulario de captación de leads8 min de lectura

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.

Preet SutharFundador, Ikiform

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

Una server route de Nuxt respondiendo a un formulario hidratado y a un envío de formulario normalcon JavaScriptsin JavaScript$fetchCuerpo JSONlead.post.tscomprobaciónde origen, Zod{ next }respuestaJSONnavigateToexternal: trueerror.data.data.fieldsse muestranbajo cadacampo<formaction>urlencodedlead.post.tsmismavalidaciónsendRedirect303Cal.com o?invalid=POSTcreateError(400)POSTUna server route de Nuxt respondiendo a un formulario hidratado y a un envío de formulario normalcon JavaScriptsin JavaScript$fetchCuerpo JSONlead.post.tscomprobación deorigen, Zod{ next }respuesta JSONnavigateToexternal: trueerror.data.data.fieldsse muestranbajo cadacampo<form action>urlencodedlead.post.tsmisma validaciónsendRedirect303Cal.com o ?invalid=POSTPOSTcreateError(400)
  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 cubre la clave y el formulario.

archivos
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
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 campoTipo en IkiformLo pone
name, email, companyTexto, email, textoEl visitante
company_sizeSelección: 1_10, 11_50, 51_200, 201_1000, 1000_plusEl visitante
budgetSelección: under_10k, 10k_50k, 50k_plus, unsureEl visitante
timelineSelección: this_quarter, next_quarter, later, researchingEl visitante
messageTextarea, opcionalEl visitante
utm_source, utm_medium, utm_campaign, referrer, landing_pageTexto, opcionalTu servidor, desde la primera visita
fitSelección: qualified, nurtureTu 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 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
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
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
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
<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.

Notas de despliegue

HostQué debes saber
VercelNitro detecta Vercel y genera funciones. Define NUXT_IKIFORM_SECRET_KEY en las variables de entorno.
NetlifyNitro también detecta Netlify. Define la variable en los ajustes del sitio.
CloudflareHaz 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.

  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

ProblemaQué hacer
navigateTo lanza un error cuando un lead se cualificaFalta external: true. navigateTo rechaza sin él las URLs absolutas a otros sitios.
La clave está vacía en producciónLa 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 proxyEl 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ágenesSe quitó la comprobación de accept. Limita el middleware a las cargas de página.
422 validation_failedEl 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.