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.
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.
- 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.
- SvelteKit resuelve la petición y renderiza la página como siempre.
- Al enviar, use:enhance envía el formulario con fetch y activa el estado de envío.
- La action valida con Valibot, lee la cookie y reenvía el lead con el Origin del visitante.
- Una redirección a Cal.com no puede ser una navegación del cliente, así que SvelteKit la carga como página completa.
- Un resultado fail actualiza la prop form; update con reset false deja los inputs como se escribieron.
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.
src/
env.ts
hooks.server.ts
routes/
contact-sales/
+page.server.ts
+page.svelte
thanks/+page.svelte
vite.config.ts
.envimport { 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 |
IKIFORM_SECRET_KEY en variables de entorno solo de servidor.El primer contacto en el hook handle
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
pathde una cookie en/. Definirlo explícitamente mantiene el ejemplo funcionando en SvelteKit 2, dondepathes 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
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;picklistde Valibot rechaza unselectvacío con tu mensaje, yflattenda 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 defailcomo estado HTTP.- Se reenvía la cabecera
Origindel navegador, porque un fetch desde el servidor no envía ninguna e Ikiform detecta a partir de ella las pruebas gratuitas desdelocalhost. redirect()lanza una excepción, así que no se ejecuta nada después. Desde SvelteKit 2 se llama sinthrow, 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 = falselo deja explícito por si un layout padre activa el prerenderizado.
La página
<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
| 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.
- Ejecuta
vite dev, abre/?utm_source=test&utm_medium=emaily comprueba la cookielead_sourceen las herramientas de desarrollo. - 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. - 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.
- Envía con Later this year. Llegas a
/contact-sales/thanks. - Abre Submissions en el panel y comprueba
utm_medium,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 |
|---|---|
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
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.
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.
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.
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.