Formulaire de capture de leads8 min de lecture
Créer un formulaire de leads qualifiant avec SvelteKit
Un formulaire de leads SvelteKit : le hook handle enregistre les UTM du premier contact, une form action valide avec Valibot et redirect() envoie les leads qualifiés vers un agenda.
Enregistrez les tags UTM et le referrer dans le hook handle au premier GET, dans un cookie httpOnly. Validez le lead dans une form action avec Valibot, renvoyez fail() avec les erreurs et les valeurs quand il ne passe pas, et transmettez les leads valides avec l'attribution à votre backend de formulaires. Appelez ensuite redirect(303, url, { external: true }) pour envoyer les leads qualifiés vers votre outil de réservation ; use:enhance suit la redirection vers l'autre site.
L'onglet SvelteKit du guide principal transmet un lead depuis une form action et affiche une ligne de remerciement. Cette page construit la version que demande une équipe commerciale : taille d'entreprise, budget et calendrier validés côté serveur, la campagne derrière chaque lead, et un passage vers votre agenda quand le lead correspond.
Ce que change SvelteKit
Les form actions sont la façon native de SvelteKit de traiter un envoi, et elles se dégradent proprement : sans JavaScript, le navigateur envoie et suit lui-même les redirections, et avec use:enhance, la même action s'exécute via fetch. Un résultat de redirection vers une autre origine retombe sur un chargement complet de la page : le passage vers Cal.com ne demande donc aucun code client supplémentaire.
SvelteKit vous donne aussi un hook, handle, qui enveloppe chaque requête serveur. C'est le bon endroit pour enregistrer le premier contact. SvelteKit 3 a changé deux choses qui concernent ce formulaire : une redirection vers un autre site exige { external: true }, et les secrets se déclarent dans src/env.ts et se lisent depuis $app/env/private à l'exécution.
- Le hook handle voit chaque GET. Sans cookie lead_source et avec un tag UTM ou un referrer externe, il en pose un.
- SvelteKit résout la requête et affiche la page normalement.
- À l'envoi, use:enhance envoie le formulaire avec fetch et active l'état d'attente.
- L'action valide avec Valibot, lit le cookie et transmet le lead avec l'Origin du visiteur.
- Une redirection vers Cal.com ne peut pas être une navigation côté client : SvelteKit la charge donc comme une page complète.
- Un résultat fail met à jour la prop form ; update avec reset à false garde les inputs tels que saisis.
Mise en place du projet
Partez d'un projet SvelteKit 3, installez valibot et ajoutez votre adaptateur. Mettez la clé secrète dans .env sous le nom IKIFORM_SECRET_KEY et déclarez-la dans src/env.ts. Elle est privée et lue à l'exécution par défaut, et SvelteKit refuse d'importer $app/env/private dans du code client. La page SvelteKit de la documentation couvre la clé et le formulaire.
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: {},
});Ajoutez les champs dans l'onglet Fields du formulaire, pour qu'Ikiform refasse les vérifications et refuse les champs que vous n'avez pas définis.
| Id du champ | Type dans Ikiform | Renseigné par |
|---|---|---|
name, email, company | Texte, e-mail, texte | Le visiteur |
company_size | Select : 1_10, 11_50, 51_200, 201_1000, 1000_plus | Le visiteur |
budget | Select : under_10k, 10k_50k, 50k_plus, unsure | Le visiteur |
timeline | Select : this_quarter, next_quarter, later, researching | Le visiteur |
message | Textarea, facultatif | Le visiteur |
utm_source, utm_medium, utm_campaign, referrer, landing_page | Texte, facultatif | Votre serveur, depuis la première visite |
fit | Select : qualified, nurture | Votre serveur, après validation |
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le premier contact dans le 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);
};- Seules les requêtes GET comptent : le POST de l'action ne pose donc jamais le cookie.
- SvelteKit 3 donne
/commepathpar défaut aux cookies. Le définir explicitement garde l'exemple fonctionnel sous SvelteKit 2, oùpathest obligatoire. - Un referrer provenant de votre propre hôte est ignoré : les clics internes ne remplacent donc pas la vraie source.
- Le hook ne s'exécute pas pour les pages prérendues servies en fichiers. Laissez les landing pages de campagne rendues à la demande, sinon les tags ne l'atteignent jamais.
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;- Le
picklistde Valibot rejette unselectvide avec votre message, etflattendonne une liste de messages par champ. fail()renvoie aussi les valeurs, car la page n'en a aucune autre copie après un envoi natif. SvelteKit 3 envoie le statut defailcomme statut HTTP.- L'en-tête
Origindu navigateur est transmis, car un fetch côté serveur n'en envoie aucun, et c'est grâce à lui qu'Ikiform détecte les tests gratuits depuislocalhost. redirect()lève une exception : rien de ce qui suit ne s'exécute. Depuis SvelteKit 2, on l'appelle sansthrow, et depuis SvelteKit 3, une cible sur une autre origine exige{ external: true }, sinon l'action échoue au lieu de rediriger.- Les pages avec des actions ne peuvent pas être prérendues.
prerender = falsele rend explicite au cas où un layout parent activerait le prérendu.
La page
<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 exécute le comportement par défaut via update(), mais reset: false garde les inputs après un échec. Sans JavaScript, les données fail de l'action reviennent dans la prop form et les attributs value remplissent à nouveau chaque champ.
Honeypot et vérification humaine
Le honeypot fait partie du schéma : il atteint donc Ikiform, et un honeypot rempli est stocké comme spam ; l'action l'envoie vers la page de remerciement. La vérification CSRF intégrée de SvelteKit rejette déjà les envois de formulaire depuis d'autres origines. La vérification humaine d'Ikiform est ignorée avec la clé secrète : vérifiez donc un token Turnstile dans l'action si vous en avez besoin, comme dans Turnstile sur un formulaire.
Notes de déploiement
| Hébergeur | À savoir |
|---|---|
| Vercel | @sveltejs/adapter-vercel, ou adapter-auto. Ajoutez IKIFORM_SECRET_KEY dans les variables d'environnement du projet. |
| Netlify | @sveltejs/adapter-netlify. Définissez la clé dans les réglages du site. |
| Cloudflare | @sveltejs/adapter-cloudflare. Stockez la clé avec wrangler secret put. Elle n'existe qu'à l'exécution, ce que gère $app/env/private, car les variables déclarées sont lues au démarrage de l'application. |
Vérifiez que tout fonctionne
Les envois depuis vite dev portent une origine localhost. Ikiform n'a pas d'offre gratuite. Les tests sont gratuits : un espace de travail sans offre accepte 50 soumissions de test par mois depuis localhost. Pour passer en production, choisissez Pro à partir de 6 $/mois pour 500 soumissions, ou payez une seule fois : 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais. Voir les tarifs.
- Lancez
vite dev, ouvrez/?utm_source=test&utm_medium=emailet vérifiez le cookielead_sourcedans les outils de développement. - Sur
/contact-sales, choisissez une taille d'entreprise, laissez Budget vide et envoyez. L'erreur de budget s'affiche et vos autres réponses restent. - Envoyez comme un lead qualifié : 201 à 1 000 personnes, 10 k$ à 50 k$, ce trimestre. La page charge Cal.com avec le nom et l'e-mail remplis.
- Envoyez avec Later this year. Vous arrivez sur
/contact-sales/thanks. - Ouvrez Submissions dans le tableau de bord et vérifiez
utm_medium,landing_pageetfit. - Vérifiez la boîte de réception de chaque destinataire des notifications. Chacun reçoit un e-mail avec les champs, et Reply répond à l'expéditeur quand le formulaire a un champ
email.
Pièges
| Problème | Que faire |
|---|---|
403 Cross-site POST form submissions are forbidden | La vérification CSRF de SvelteKit a vu une origine qui ne correspond pas. Derrière un proxy qui ne transmet pas l'hôte, définissez paths.origin dans les options de sveltekit() dans vite.config.ts ; SvelteKit 3 a supprimé la variable ORIGIN d'adapter-node. Pour un autre de vos sites, ajoutez-le à csrf.trustedOrigins. |
| La redirection vers Cal.com échoue avec une erreur sur une URL externe | SvelteKit 3 refuse par défaut les redirections externes. Passez { external: true }, ou une liste d'autorisation comme { external: ["https://cal.com"] }, en troisième argument. |
| La page ne part pas vers Cal.com et la console affiche une erreur | Un callback use:enhance personnalisé a appelé goto() sur la redirection. goto rejette les URL externes ; appelez plutôt update() ou applyAction(). |
| Les champs se vident après une erreur | update() a été appelé sans reset: false, ou les inputs ne lisent pas form.values. |
422 validation_failed | L'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ. |
Questions
Oui. Appelez redirect(303, 'https://...', { external: true }) dans l'action ; SvelteKit 3 refuse une cible externe sans cette option. Sans JavaScript, le navigateur la suit, et avec use:enhance, SvelteKit retombe sur un chargement complet de la page car l'URL ne fait pas partie de votre application.
Déclarez-la dans src/env.ts avec defineEnvVars et importez-la depuis $app/env/private. Elle est lue à l'exécution sauf si vous définissez static: true : les secrets disponibles seulement à l'exécution, comme ceux des Cloudflare Workers, fonctionnent donc. Les modules $env fonctionnent toujours mais sont obsolètes.
Renvoyez-les dans fail(400, { errors, values }) et liez la valeur de chaque input à form.values. Avec use:enhance, appelez update({ reset: false }) pour que le formulaire ne soit pas vidé.
Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.