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

Publié le 2026-10-25 · https://ikiform.com/fr/guides/lead-capture/sveltekit

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](https://ikiform.com/fr/guides/lead-capture.md#form) 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.

Schéma : Ce que fait SvelteKit de chaque type de résultat d'action

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](https://ikiform.com/fr/docs/guides/sveltekit.md) couvre la clé et le formulaire.

`fichiers`:

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

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 |

> 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](https://ikiform.com/fr/docs/rate-limits.md) 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`:

```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`:

```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`:

```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](https://ikiform.com/fr/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/fr/pricing).

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

### Une form action SvelteKit peut-elle rediriger vers une URL externe ?

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.

### Comment lire la clé secrète dans SvelteKit 3 ?

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.

### Comment garder les valeurs du formulaire après l'échec d'une action SvelteKit ?

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

### Ikiform est-il gratuit ?

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.

## Autres guides

- [Formulaire de capture de leads](https://ikiform.com/fr/guides/lead-capture.md): Un formulaire de capture de leads sur votre propre site, qui vous écrit avec le lead en adresse de réponse, alerte le chat de l'équipe et alimente votre CRM, pour répondre tant que le lead est encore là.
- [Formulaire de leads Nuxt](https://ikiform.com/fr/guides/lead-capture/nuxt.md): Un formulaire de leads Nuxt : un middleware serveur enregistre les UTM du premier contact, une route serveur validée par Zod sert fetch et les envois classiques, et les bons leads réservent un appel.
- [Formulaire de leads Astro](https://ikiform.com/fr/guides/lead-capture/astro.md): Un formulaire de leads Astro construit sur les Actions : validation Zod, UTM du premier contact sur un site statique, une page de contact à la demande et une redirection vers votre outil de réservation.
- [Formulaire de demande de démo](https://ikiform.com/fr/guides/lead-capture/demo-request.md): Un formulaire de réservation de démo qui stocke le lead, puis envoie le visiteur vers Cal.com ou Calendly avec son nom et son e-mail déjà remplis.
- [Formulaire de demande de devis](https://ikiform.com/fr/guides/lead-capture/quote-request.md): Un formulaire de demande de devis qui reçoit les briefs sous forme de liens partagés plutôt qu'en pièces jointes, fixe une échéance à chaque demande et garde les leads riches en liens hors du spam.
- [Formulaire de demande de consultation](https://ikiform.com/fr/guides/lead-capture/consultation-request.md): Un formulaire de demande de consultation pour comptables, avocats, coachs et cabinets, qui demande comment et quand appeler, et garde les détails sensibles pour l'appel.
