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

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

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](https://ikiform.com/es/guides/lead-capture.md#form) 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`.

Diagrama: Cómo viaja un lead de Next.js desde el primer clic en un anuncio hasta una llamada agendada

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](https://ikiform.com/es/docs/guides/nextjs.md) explica en detalle la clave y la configuración del formulario.

`archivos`:

```text
app/
  contact-sales/
    page.tsx
    actions.ts
    lead-form.tsx
    thanks/page.tsx
lib/
  lead.ts
proxy.ts
.env.local
```

`.env.local`:

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

## Atribución del primer contacto en proxy.ts

`proxy.ts`:

```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`:

```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`:

```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`:

```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`:

```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](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/es/pricing).

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

| 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

### ¿Puede una server action de Next.js redirigir a una URL externa?

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.

### ¿Por qué capturar los parámetros UTM en proxy.ts en lugar de en la página de contacto?

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.

### ¿Necesito una base de datos para cualificar leads en Next.js?

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.

### ¿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 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 leads en Astro](https://ikiform.com/es/guides/lead-capture/astro.md): Un formulario de leads en Astro basado en Actions: validación con Zod, UTMs del primer contacto en un sitio estático, una página de contacto bajo demanda y una redirección a tu agenda.
- [Formulario de leads en React Router](https://ikiform.com/es/guides/lead-capture/react-router.md): Un formulario de leads en React Router en modo framework: middleware raíz con una cookie firmada de primer contacto, una action de ruta que valida y una redirección a tu agenda.
- [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.
