# Crea un formulario de leads con cualificación en SvelteKit

> Un formulario de leads en SvelteKit: el hook handle registra las UTM del primer contacto, una form action valida con Valibot y redirect() manda a los leads cualificados a un calendario.

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

Registra las etiquetas UTM y el referrer en el hook `handle` en el primer GET, en una cookie httpOnly. Valida el lead en una form action con Valibot, devuelve `fail()` con los errores y los valores cuando no pase, y reenvía los leads válidos con la atribución a tu backend de formularios. Después llama a `redirect(303, url, { external: true })` para mandar a los leads cualificados a tu agenda; `use:enhance` sigue la redirección al otro sitio.

La [pestaña de SvelteKit de la guía principal](https://ikiform.com/es/guides/lead-capture.md#form) reenvía un lead desde una form action y muestra una línea de agradecimiento. Esta página construye la versión que pide un equipo de ventas: tamaño de la empresa, presupuesto y plazos validados en el servidor, la campaña detrás de cada lead y el paso a tu calendario cuando el lead encaja.

## Qué cambia con SvelteKit

Las form actions son la forma nativa de SvelteKit de gestionar un envío, y degradan limpiamente: sin JavaScript el navegador envía y sigue las redirecciones por sí mismo, y con `use:enhance` la misma action se ejecuta mediante `fetch`. Un resultado de redirección a otro origen recurre a una carga completa de la página, así que el paso a Cal.com no necesita código extra en el cliente.

SvelteKit también te da un hook, `handle`, que envuelve cada petición al servidor. Es el sitio adecuado para registrar el primer contacto. SvelteKit 3 cambió dos cosas que afectan a este formulario: una redirección a otro sitio necesita `{ external: true }`, y los secretos se declaran en `src/env.ts` y se leen de `$app/env/private` en tiempo de ejecución.

Diagrama: Qué hace SvelteKit con cada tipo de resultado de una action

1. El hook handle ve cada GET. Si aún no hay cookie lead_source y hay una etiqueta UTM o un referrer externo, la pone.
2. SvelteKit resuelve la petición y renderiza la página como siempre.
3. Al enviar, use:enhance envía el formulario con fetch y activa el estado de envío.
4. La action valida con Valibot, lee la cookie y reenvía el lead con el Origin del visitante.
5. Una redirección a Cal.com no puede ser una navegación del cliente, así que SvelteKit la carga como página completa.
6. Un resultado fail actualiza la prop form; update con reset false deja los inputs como se escribieron.

Sin JavaScript, el navegador hace esas dos cosas por sí mismo: sigue el 303 o renderiza la página con form.

## Configuración del proyecto

Parte de un proyecto de SvelteKit 3, instala `valibot` y añade tu adaptador. Pon la clave secreta en `.env` como `IKIFORM_SECRET_KEY` y declárala en `src/env.ts`. Por defecto es privada y se lee en tiempo de ejecución, y SvelteKit se niega a importar `$app/env/private` en código de cliente. La [página de la documentación de SvelteKit](https://ikiform.com/es/docs/guides/sveltekit.md) cubre la clave y el formulario.

`archivos`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
```

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

Añade los campos en la pestaña **Fields** del formulario, para que Ikiform repita las comprobaciones y rechace los campos que no definiste.

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

## El primer contacto en el hook handle

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";

const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

export const handle: Handle = async ({ event, resolve }) => {
  if (event.request.method === "GET" && !event.cookies.get("lead_source")) {
    const referer = event.request.headers.get("referer") ?? "";
    const from = URL.canParse(referer) ? new URL(referer) : null;
    const external = from && from.host !== event.url.host ? from.href.slice(0, 300) : "";
    const source = new URLSearchParams();
    for (const tag of TAGS) source.set(tag, event.url.searchParams.get(tag)?.slice(0, 100) ?? "");

    if (external || TAGS.some((tag) => source.get(tag))) {
      source.set("referrer", external);
      source.set("landing_page", event.url.pathname.slice(0, 200));
      event.cookies.set("lead_source", source.toString(), {
        path: "/",
        httpOnly: true,
        sameSite: "lax",
        maxAge: 60 * 60 * 24 * 30,
      });
    }
  }
  return resolve(event);
};
```

- Solo cuentan las peticiones GET, así que el propio POST de la action nunca pone la cookie.
- SvelteKit 3 pone por defecto el `path` de una cookie en `/`. Definirlo explícitamente mantiene el ejemplo funcionando en SvelteKit 2, donde `path` es obligatorio.
- Se ignora un referrer de tu propio host, así que los clics internos no sustituyen a la fuente real.
- El hook no se ejecuta para las páginas prerenderizadas servidas como archivos. Deja las landing pages de campaña renderizadas bajo demanda, o las etiquetas nunca le llegarán.

## La form action

`src/routes/contact-sales/+page.server.ts`:

```ts
import { fail, redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import * as v from "valibot";
import type { Actions } from "./$types";

export const prerender = false;

const Lead = v.object({
  name: v.pipe(v.string(), v.trim(), v.nonEmpty("Tell us who to reply to."), v.maxLength(120)),
  email: v.pipe(v.string(), v.trim(), v.email("Enter a work email we can reply to.")),
  company: v.pipe(v.string(), v.trim(), v.nonEmpty("Which company is this for?"), v.maxLength(200)),
  company_size: v.picklist(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], "Pick a company size."),
  budget: v.picklist(["under_10k", "10k_50k", "50k_plus", "unsure"], "Pick a budget range."),
  timeline: v.picklist(["this_quarter", "next_quarter", "later", "researching"], "Pick a timeline."),
  message: v.pipe(v.string(), v.trim(), v.maxLength(5000, "Keep it under 5,000 characters.")),
  _gotcha: v.string(),
});

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export const actions = {
  default: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const values = Object.fromEntries(Object.keys(Lead.entries).map((key) => [key, String(form.get(key) ?? "")]));
    const parsed = v.safeParse(Lead, values);
    if (!parsed.success) {
      const nested = v.flatten<typeof Lead>(parsed.issues).nested ?? {};
      const errors = Object.fromEntries(Object.entries(nested).map(([key, list]) => [key, list?.[0] ?? ""]));
      return fail(400, { errors, values });
    }

    const lead = parsed.output;
    const stored = new URLSearchParams(cookies.get("lead_source") ?? "");
    const qualified =
      lead.company_size !== "1_10" &&
      (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
      (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");

    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("origin") ?? url.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) {
      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 fail(response.status >= 400 ? response.status : 502, { errors, values });
    }

    if (!qualified || lead._gotcha) redirect(303, "/contact-sales/thanks");
    redirect(303, `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`, { external: true });
  },
} satisfies Actions;
```

- `picklist` de Valibot rechaza un `select` vacío con tu mensaje, y `flatten` da una lista de mensajes por campo.
- `fail()` también devuelve los valores, porque la página no tiene otra copia de ellos tras un envío nativo. SvelteKit 3 envía el estado de `fail` como estado HTTP.
- Se reenvía la cabecera `Origin` del navegador, porque un fetch desde el servidor no envía ninguna e Ikiform detecta a partir de ella las pruebas gratuitas desde `localhost`.
- `redirect()` lanza una excepción, así que no se ejecuta nada después. Desde SvelteKit 2 se llama sin `throw`, y desde SvelteKit 3 un destino en otro origen necesita `{ external: true }`, o la action falla en lugar de redirigir.
- Las páginas con actions no se pueden prerenderizar. `prerender = false` lo deja explícito por si un layout padre activa el prerenderizado.

## La página

`src/routes/contact-sales/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let pending = $state(false);
  const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
  const values = $derived<Record<string, string | undefined>>(form?.values ?? {});

  const selects = [
    { id: "company_size", label: "Company size", options: [["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"]] },
    { id: "budget", label: "Budget", options: [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]] },
    { id: "timeline", label: "When do you want to start?", options: [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]] },
  ];
</script>

<form
  method="POST"
  use:enhance={() => {
    pending = true;
    return async ({ update }) => {
      await update({ reset: false });
      pending = false;
    };
  }}
>
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required value={values.name ?? ""} />
  {#if errors.name}<p role="alert">{errors.name}</p>{/if}

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required value={values.email ?? ""} />
  {#if errors.email}<p role="alert">{errors.email}</p>{/if}

  <label for="company">Company</label>
  <input id="company" name="company" autocomplete="organization" required value={values.company ?? ""} />
  {#if errors.company}<p role="alert">{errors.company}</p>{/if}

  {#each selects as select (select.id)}
    <label for={select.id}>{select.label}</label>
    <select id={select.id} name={select.id} required value={values[select.id] ?? ""}>
      <option value="" disabled>Choose one</option>
      {#each select.options as [value, text] (value)}
        <option {value}>{text}</option>
      {/each}
    </select>
    {#if errors[select.id]}<p role="alert">{errors[select.id]}</p>{/if}
  {/each}

  <label for="message">Anything we should know?</label>
  <textarea id="message" name="message" rows="4">{values.message ?? ""}</textarea>

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  {#if errors.form}<p role="alert">{errors.form}</p>{/if}
  <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
```

`use:enhance` ejecuta el comportamiento por defecto mediante `update()`, pero `reset: false` conserva los inputs tras un fallo. Sin JavaScript, los datos de `fail` de la action vuelven en la prop `form` y los atributos `value` rellenan de nuevo cada campo.

### Honeypot y verificación humana

El honeypot está en el esquema, así que llega a Ikiform y uno relleno se guarda como spam; la action lo manda a la página de agradecimiento. La comprobación CSRF integrada de SvelteKit ya rechaza los envíos de formulario desde otros orígenes. La verificación humana de Ikiform se salta con la clave secreta, así que verifica un token de Turnstile en la action si lo necesitas, como en [Turnstile en un formulario](https://ikiform.com/es/guides/spam-protection/cloudflare-turnstile.md).

## Notas de despliegue

| Host | Qué debes saber |
| --- | --- |
| Vercel | `@sveltejs/adapter-vercel`, o `adapter-auto`. Añade `IKIFORM_SECRET_KEY` en las variables de entorno del proyecto. |
| Netlify | `@sveltejs/adapter-netlify`. Define la clave en los ajustes del sitio. |
| Cloudflare | `@sveltejs/adapter-cloudflare`. Guarda la clave con `wrangler secret put`. Solo existe en tiempo de ejecución, lo que `$app/env/private` gestiona, porque las variables declaradas se leen al arrancar la app. |

## Comprueba que funciona

Los envíos desde `vite 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 `vite dev`, abre `/?utm_source=test&utm_medium=email` y comprueba la cookie `lead_source` en las herramientas de desarrollo.
2. En `/contact-sales`, elige un tamaño de empresa, deja **Budget** vacío y envía. Aparece el error del presupuesto y tus otras respuestas se mantienen.
3. Envía como lead cualificado: de 201 a 1.000 personas, de 10k $ a 50k $, este trimestre. La página carga Cal.com con el nombre y el email rellenos.
4. Envía con **Later this year**. Llegas a `/contact-sales/thanks`.
5. Abre **Submissions** en el panel y comprueba `utm_medium`, `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 |
| --- | --- |
| `403 Cross-site POST form submissions are forbidden` | La comprobación CSRF de SvelteKit vio un origen que no coincide. Detrás de un proxy que no pasa el host, define `paths.origin` en las opciones de `sveltekit()` en `vite.config.ts`; SvelteKit 3 eliminó la variable `ORIGIN` de adapter-node. Para otro sitio tuyo, añádelo a `csrf.trustedOrigins`. |
| La redirección a Cal.com falla con un error sobre una URL externa | SvelteKit 3 rechaza por defecto las redirecciones externas. Pasa `{ external: true }`, o una lista de permitidos como `{ external: ["https://cal.com"] }`, como tercer argumento. |
| La página no se va a Cal.com y la consola muestra un error | Un callback personalizado de `use:enhance` llamó a `goto()` con la redirección. `goto` rechaza las URLs externas; llama en su lugar a `update()` o `applyAction()`. |
| Los campos se vacían tras un error | Se llamó a `update()` sin `reset: false`, o los inputs no leen `form.values`. |
| `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

### ¿Puede una form action de SvelteKit redirigir a una URL externa?

Sí. Llama a redirect(303, 'https://...', { external: true }) en la action; SvelteKit 3 rechaza un destino externo sin esa opción. Sin JavaScript el navegador la sigue, y con use:enhance SvelteKit recurre a una carga completa de la página porque la URL no forma parte de tu app.

### ¿Cómo leo la clave secreta en SvelteKit 3?

Declárala en src/env.ts con defineEnvVars e impórtala de $app/env/private. Se lee en tiempo de ejecución salvo que pongas static: true, así que funcionan los secretos que solo existen en ejecución, como los de Cloudflare Workers. Los módulos $env siguen funcionando, pero están obsoletos.

### ¿Cómo conservo los valores del formulario tras una action fallida en SvelteKit?

Devuélvelos en fail(400, { errors, values }) y enlaza el value de cada input a form.values. Con use:enhance, llama a update({ reset: false }) para que el formulario no se vacíe.

### ¿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 leads en Nuxt](https://ikiform.com/es/guides/lead-capture/nuxt.md): 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.
- [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 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 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.
