Formulario de captación de leads

Formulario de captación de leads8 min de lectura

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.

Preet SutharFundador, Ikiform

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

Qué hace SvelteKit con cada tipo de resultado de una actioncada GETenvíohook handle¿aún sincookie?cookies.setlead_source,httpOnlyresolve(event)renderiza lapáginause:enhancePOST con fetchaction pordefectoValibot y luegoIkiformredirect(303)otro origen:carga completaprop formerrores yvalores, sinreiniciaretiquetas o referrerform datafail(400)Qué hace SvelteKit con cada tipo de resultado de una actioncada GETenvíohook handle¿aún sin cookie?cookies.setlead_source,httpOnlyresolve(event)renderiza la páginause:enhancePOST con fetchaction pordefectoValibot y luegoIkiformredirect(303)otro origen: cargacompletapropformerrores yvalores,sinreiniciaretiquetas o referrerform datafail(400)
  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 cubre la clave y el formulario.

archivos
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
src/env.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 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.

El primer contacto en el hook handle

src/hooks.server.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
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
<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.

Notas de despliegue

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

  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

ProblemaQué hacer
403 Cross-site POST form submissions are forbiddenLa 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 externaSvelteKit 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 errorUn 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 errorSe llamó a update() sin reset: false, o los inputs no leen form.values.
422 validation_failedEl 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

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