Formulario de captación de leads

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.

Preet SutharFundador, Ikiform

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.

Cómo viaja un lead de Next.js desde el primer clic en un anuncio hasta una llamada agendadaprimera visitaenvíoanuncio o enlace?utm_source=...proxy.tspone la cookielead_sourcepágina estáticaservida desde cachéserver actionZod, lee la cookieIkiformlead con UTMs yencajeredirect()Cal.com, prerrellenadoestado de laactionerrores y valoresescritosGETnext()JSONok422 o error de ZodCómo viaja un lead de Next.js desde el primer clic en un anuncio hasta una llamada agendadaprimera visitaenvíoanuncio o enlace?utm_source=...proxy.tspone la cookielead_sourcepágina estáticaservida desde cachéserver actionZod, lee la cookieIkiformlead con UTMs yencajeredirect()Cal.com,prerrellenadoestadode laactionerrores yvaloresescritosGETnext()JSONok422 o error de Zod
  1. El visitante llega desde un enlace de campaña con etiquetas UTM o desde un enlace de otro sitio.
  2. proxy.ts guarda las etiquetas, el referrer y la ruta de entrada en una cookie httpOnly, una sola vez.
  3. Las páginas de precios y de contacto siguen siendo estáticas y salen de la caché.
  4. Al enviar, la server action valida los campos con Zod y lee la cookie.
  5. Envía el lead, la atribución y un encaje calculado a Ikiform con la clave secreta.
  6. 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.
  7. Un error de validación vuelve como estado de la action, con todos los valores que escribió el visitante.
La cookie lleva el primer contacto de una página a otra, así que la página de contacto nunca tiene que leer la URL.

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.

archivos
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
.env.local
IKIFORM_SECRET_KEY=ik_sk_your_secret_key

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

Atribución del primer contacto en proxy.ts

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 has si 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.
  • URLSearchParams da 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.

lib/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(),
});

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;
}
app/contact-sales/actions.ts
"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.flattenError convierte sus problemas en un mensaje por campo.
  • El navegador envía una cabecera Origin con 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 desde localhost.
  • fit se calcula en el servidor. Un visitante no puede marcarse como cualificado editando el formulario.
  • Las dos llamadas a redirect() están fuera de cualquier bloque try. redirect() funciona lanzando una excepción, y un catch se 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

app/contact-sales/lead-form.tsx
"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>
  );
}
app/contact-sales/page.tsx
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:

HostQué debes saber
VercelEl 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.
NetlifyNext.js funciona con el adaptador de Netlify sin configuración. Pon la clave en las variables de entorno del sitio, limitada a las funciones.
CloudflareDespliega 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.

  1. Ejecuta next dev y abre /pricing?utm_source=test&utm_campaign=launch. Comprueba la cookie lead_source en las herramientas de desarrollo del navegador.
  2. 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.
  3. 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.
  4. Envía como un equipo de 1 a 10 personas. Llegas a /contact-sales/thanks.
  5. Abre Submissions en el panel. Los dos leads están ahí con utm_source, landing_page 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
Los campos UTM llegan vacíosLa 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 nadaSe llamó a redirect() dentro de try. Muévelo después del bloque, como en el ejemplo.
422 con Unknown field para fit o referrerLa definición de campos del formulario no lo incluye. Añade cada id de la tabla como campo opcional.
402 test_limit_reachedEl 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 errorFalta la prop key, así que React conserva el select reiniciado. Dale a cada uno una key con su valor devuelto.

Preguntas

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