Formulario de captación de leads9 min de lectura
Crea un formulario de leads que cualifique en Next.js
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.
Captura las etiquetas de campaña y el referrer en proxy.ts en la primera visita y guárdalos en una cookie. Valida el lead con Zod en una server action, puntúalo según el tamaño de la empresa, el presupuesto y los plazos, y reenvíalo con la atribución a tu backend de formularios. Después llama a redirect() para mandar los leads cualificados a tu agenda y a todos los demás a una página de agradecimiento.
La pestaña de Next.js de la guía principal reenvía un lead y muestra una línea de agradecimiento. Esta página construye la versión para ventas: tres preguntas de cualificación comprobadas en tu servidor, datos de campaña que sobreviven a que el visitante navegue por el sitio antes de convertir, y un paso directo a tu calendario para los leads que merecen una llamada.
Qué cambia con Next.js
Dos funciones de Next.js hacen la mayor parte del trabajo. proxy.ts, el archivo que se llamaba middleware.ts antes de Next.js 16, se ejecuta antes de cada petición de página, incluso en páginas estáticas. Eso lo convierte en el lugar adecuado para leer ?utm_source= y la cabecera Referer en la primera visita, porque cuando el visitante llega a Talk to sales ambos han desaparecido de la URL. Después, la server action lee la cookie, así que la propia página de contacto puede seguir siendo estática.
La segunda es redirect(). En una server action acepta una URL absoluta, así que un lead cualificado llega a Cal.com sin código de cliente. Con JavaScript cargado, Next.js navega en el cliente; antes de la hidratación, responde al envío del formulario con un 303.
- El visitante llega desde un enlace de campaña con etiquetas UTM o desde un enlace de otro sitio.
- proxy.ts guarda las etiquetas, el referrer y la ruta de entrada en una cookie httpOnly, una sola vez.
- Las páginas de precios y de contacto siguen siendo estáticas y salen de la caché.
- Al enviar, la server action valida los campos con Zod y lee la cookie.
- Envía el lead, la atribución y un encaje calculado a Ikiform con la clave secreta.
- Un lead cualificado se redirige a la agenda con el nombre y el email ya rellenos; los demás van a la página de agradecimiento.
- Un error de validación vuelve como estado de la action, con todos los valores que escribió el visitante.
Configuración del proyecto
Parte de cualquier proyecto con App Router en Next.js 16. Instala zod, crea un formulario en el panel y una clave secreta con el scope submissions:write en API keys. La página de Next.js de la documentación explica en detalle la clave y la configuración del formulario.
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_keyDespués añade los campos en la pestaña Fields del formulario, para que Ikiform también los haga cumplir. Sin definición, Ikiform guarda lo que le envíes; con una, un campo desconocido es un 422, que es lo que quieres en cuanto la atribución va en el payload.
| 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.Atribución del primer contacto en 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" },
],
},
],
};- La cookie se pone una vez y nunca se sobrescribe, así que registra la primera campaña, no el último clic interno. Quita la comprobación
hassi prefieres el último contacto. - Se ignora un referrer de tu propio host. Si no, cada vista de página después de la primera contaría como una referencia desde ti mismo.
- El matcher se salta los archivos estáticos y los prefetch del router. Los prefetch llevan tu propia página como referrer y, si no, ejecutarían el proxy por cada enlace en pantalla.
URLSearchParamsda un valor de cookie compacto y un parser seguro. La action solo lee las cinco claves que conoce, así que un visitante que edite la cookie no puede añadir campos.
Validación, puntuación y el traspaso
El esquema vive en su propio módulo para que la action y cualquier futura ruta de API lo compartan. La regla de encaje es deliberadamente simple: al menos once personas, un presupuesto de 10.000 $ o más y una fecha de inicio dentro de dos 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 })}`);
}- Zod se ejecuta antes de que nada salga de tu servidor, y
z.flattenErrorconvierte sus problemas en un mensaje por campo. - El navegador envía una cabecera
Origincon cada post de una server action, y la action la reenvía: un fetch del lado del servidor no envía ninguna, e Ikiform detecta con ella las pruebas gratuitas desdelocalhost. fitse calcula en el servidor. Un visitante no puede marcarse como cualificado editando el formulario.- Las dos llamadas a
redirect()están fuera de cualquier bloquetry.redirect()funciona lanzando una excepción, y uncatchse la tragaría. - Un honeypot relleno se envía igualmente, así que Ikiform lo registra como spam, pero nunca llega a tu calendario.
El formulario, con y sin 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 da al formulario su indicador de pendiente y los errores devueltos. Como la action es una función de servidor, el formulario también se envía antes de la hidratación: Next.js renderiza el resultado en el servidor y el visitante ve los mismos errores, solo que con una carga completa de página.
React reinicia un formulario después de ejecutar su action, así que cada camino de error devuelve values y cada input lo vuelve a leer como defaultValue. La key de cada select lo obliga a volver a montarse con la opción devuelta; un select no recoge por sí solo un defaultValue cambiado.
Honeypot y verificación humana
El honeypot oculto se reenvía tal cual. La verificación humana de Ikiform no se ejecuta en un envío con clave secreta, así que si este formulario atrae tráfico de bots, verifica un token de Turnstile en la action antes de enviar, como en Turnstile en un formulario.
Renderizado y despliegue
Nada en la página de contacto lee datos de la petición, así que next build la prerenderiza. El proxy y la action se ejecutan en cada petición. Comprueba cómo los ejecuta tu hosting:
| Host | Qué debes saber |
|---|---|
| Vercel | El proxy y las server actions funcionan sin configuración. Añade IKIFORM_SECRET_KEY en las variables de entorno del proyecto para Production y Preview. |
| Netlify | Next.js funciona con el adaptador de Netlify sin configuración. Pon la clave en las variables de entorno del sitio, limitada a las funciones. |
| Cloudflare | Despliega con el adaptador OpenNext para Cloudflare y guarda la clave con wrangler secret put. Consulta su documentación sobre el soporte actual del proxy antes de depender de la cookie. |
Comprueba que funciona
Los envíos desde next 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
next devy abre/pricing?utm_source=test&utm_campaign=launch. Comprueba la cookielead_sourceen las herramientas de desarrollo del navegador. - Navega hasta
/contact-sales, deja vacío Company size y envía. El error aparece bajo el campo y los demás valores se mantienen. - Envía como lead cualificado: de 51 a 200 personas, de 10.000 $ a 50.000 $, este trimestre. Llegas a Cal.com con tu nombre y tu email ya rellenos.
- Envía como un equipo de 1 a 10 personas. Llegas a
/contact-sales/thanks. - Abre Submissions en el panel. Los dos leads están ahí con
utm_source,landing_pageyfit. - 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 |
|---|---|
| Los campos UTM llegan vacíos | La cookie se puso antes de que añadieras las etiquetas, o el archivo del proxy no está en la raíz del proyecto (o en src/ si usas esa carpeta). Borra la cookie y recarga con las etiquetas. |
| La redirección nunca ocurre y el formulario no muestra nada | Se llamó a redirect() dentro de try. Muévelo después del bloque, como en el ejemplo. |
422 con Unknown field para fit o referrer | La definición de campos del formulario no lo incluye. Añade cada id de la tabla como campo opcional. |
402 test_limit_reached | El espacio de trabajo no tiene plan y ya usó sus 50 pruebas gratuitas desde localhost este mes. Elige un plan o usa Send test en el panel. |
| Los selects vuelven a Choose one después de un error | Falta la prop key, así que React conserva el select reiniciado. Dale a cada uno una key con su valor devuelto. |
Preguntas
Sí. redirect() de next/navigation acepta una URL absoluta. En una server action navega en el cliente cuando JavaScript está cargado y responde con un 303 a un formulario enviado antes de la hidratación. Llámalo fuera de cualquier bloque try, porque funciona lanzando una excepción.
Los visitantes rara vez convierten en la página a la que llegan. El proxy se ejecuta en la primera petición, guarda las etiquetas y el referrer en una cookie, y la server action la lee después. La página de contacto sigue siendo estática porque nunca lee la URL.
No. La server action puntúa el lead, lo reenvía a Ikiform con el resultado en un campo fit y redirige. Ikiform guarda el lead, avisa por email a tu equipo y puede enviar un webhook a tu CRM.
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.