Formulaire de capture de leads

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.

Preet SutharFondateur, Ikiform

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.

Ce que fait SvelteKit de chaque type de résultat d'actionchaque GETenvoihook handlepas encore decookie ?cookies.setlead_source,httpOnlyresolve(event)affiche la pageuse:enhancePOST fetchaction pardéfautValibot, puisIkiformredirect(303)autre origine :chargementcompletprop formerreurs etvaleurs, sansréinitialisationtags ou referrerdonnées du formulairefail(400)Ce que fait SvelteKit de chaque type de résultat d'actionchaque GETenvoihook handlepas encore decookie ?cookies.setlead_source,httpOnlyresolve(event)affiche la pageuse:enhancePOST fetchaction pardéfautValibot, puisIkiformredirect(303)autre origine :chargement completpropformerreurs etvaleurs,sansréinitialisationtags ou referrerdonnées du formulairefail(400)
  1. Le hook handle voit chaque GET. Sans cookie lead_source et avec un tag UTM ou un referrer externe, il en pose un.
  2. SvelteKit résout la requête et affiche la page normalement.
  3. À l'envoi, use:enhance envoie le formulaire avec fetch et active l'état d'attente.
  4. L'action valide avec Valibot, lit le cookie et transmet le lead avec l'Origin du visiteur.
  5. Une redirection vers Cal.com ne peut pas être une navigation côté client : SvelteKit la charge donc comme une page complète.
  6. Un résultat fail met à jour la prop form ; update avec reset à false garde les inputs tels que saisis.
Sans JavaScript, le navigateur fait lui-même les deux mêmes choses : il suit le 303 ou affiche la page avec form.

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.

fichiers
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: {},
});

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 champType dans IkiformRenseigné par
name, email, companyTexte, e-mail, texteLe visiteur
company_sizeSelect : 1_10, 11_50, 51_200, 201_1000, 1000_plusLe visiteur
budgetSelect : under_10k, 10k_50k, 50k_plus, unsureLe visiteur
timelineSelect : this_quarter, next_quarter, later, researchingLe visiteur
messageTextarea, facultatifLe visiteur
utm_source, utm_medium, utm_campaign, referrer, landing_pageTexte, facultatifVotre serveur, depuis la première visite
fitSelect : qualified, nurtureVotre serveur, après validation
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Le premier contact dans le 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);
};
  • Seules les requêtes GET comptent : le POST de l'action ne pose donc jamais le cookie.
  • SvelteKit 3 donne / comme path par défaut aux cookies. Le définir explicitement garde l'exemple fonctionnel sous SvelteKit 2, où path est 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

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;
  • Le picklist de Valibot rejette un select vide avec votre message, et flatten donne 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 de fail comme statut HTTP.
  • L'en-tête Origin du 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 depuis localhost.
  • redirect() lève une exception : rien de ce qui suit ne s'exécute. Depuis SvelteKit 2, on l'appelle sans throw, 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 = false le rend explicite au cas où un layout parent activerait le prérendu.

La page

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

  1. Lancez vite dev, ouvrez /?utm_source=test&utm_medium=email et vérifiez le cookie lead_source dans les outils de développement.
  2. 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.
  3. 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.
  4. Envoyez avec Later this year. Vous arrivez sur /contact-sales/thanks.
  5. Ouvrez Submissions dans le tableau de bord et vérifiez utm_medium, landing_page et fit.
  6. 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èmeQue faire
403 Cross-site POST form submissions are forbiddenLa 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 externeSvelteKit 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 erreurUn 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 erreurupdate() a été appelé sans reset: false, ou les inputs ne lisent pas form.values.
422 validation_failedL'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

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.