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.
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.
- Con JavaScript, la página envía el objeto reactivo del lead como JSON con $fetch.
- La server route rechaza los envíos desde otros orígenes, valida con Zod y reenvía el lead con su atribución.
- Responde con JSON con la siguiente URL, y navigateTo se va a Cal.com o a la página de agradecimiento.
- Un fallo de validación se lanza con createError, y la página lee los mensajes de los campos de los datos del error.
- Sin JavaScript, el navegador envía el formulario URL-encoded a la misma ruta.
- 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.
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.
app/
pages/
contact-sales.vue
contact-sales/thanks.vue
server/
middleware/first-touch.ts
api/lead.post.ts
utils/lead.ts
nuxt.config.ts
.envexport 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 |
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.Middleware de servidor para el primer contacto
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/middlewareantes de cada petición y espera que no devuelvan nada. Devolver un valor terminaría la petición. - La comprobación de
acceptlimita la cookie a las cargas de página, así que las llamadas a la API y los assets se la saltan. getCookie,setCookiey los demás helpers de h3 se importan automáticamente enserver/.- Las páginas prerenderizadas con
routeRulesonuxt generatese 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
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");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/utilsse importan automáticamente en las server routes, por esoLeadSchemaeisQualifiedno necesitan import. - La misma cabecera
Origindel 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 desdelocalhost. createErrorpone los mensajes de los campos endata, y la respuesta de error de Nuxt los anida, así que el cliente leeerror.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
<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
| 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.
- Ejecuta
nuxt dev, abre/?utm_source=test&utm_campaign=launchy comprueba la cookielead_sourceen las herramientas de desarrollo. - En
/contact-sales, deja Timeline vacío y envía. Aparece el error y las demás respuestas se mantienen. - Desactiva JavaScript y envía de la misma forma. Vuelves a
/contact-sales?invalid=timelinecon el campo marcado. - 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.
- Abre Submissions en el panel y comprueba
utm_campaign,referreryfit. - 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
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.
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.
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.
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.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.