# Créer une liste d'attente avec SvelteKit

> Une liste d'attente SvelteKit 3 : un hook garde le premier ?ref= dans un cookie, une action nommée transmet l'inscription, les visiteurs qui reviennent voient qu'ils sont inscrits.

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

Lisez `?ref=` dans `hooks.server.ts` à la première requête du visiteur et gardez-le dans un cookie, pour que l'inscription soit attribuée au lien qui l'a amené même après qu'il a navigué. Une action nommée lit le cookie, transmet l'inscription à votre backend de formulaires avec une clé secrète, et définit un second cookie pour que la page accueille un visiteur qui revient au lieu de lui redemander.

Le [guide principal](https://ikiform.com/fr/guides/waitlist.md#form) a un onglet SvelteKit : une action par défaut et `use:enhance`, avec `ref` fixé à `homepage`. Cette page ajoute ce dont un vrai lancement a besoin. Les visiteurs arrivent sur un article de blog depuis une newsletter, lisent un moment et s'inscrivent deux pages plus loin. Vous voulez attribuer cette inscription à la newsletter, pas à la page où ils ont saisi leur e-mail.

## Ce qui change avec SvelteKit

Deux fonctionnalités de SvelteKit s'y prêtent bien, et une règle façonne la configuration :

| Fonctionnalité | Son rôle ici |
| --- | --- |
| `handle` dans `hooks.server.ts` | S'exécute avant chaque requête rendue côté serveur : il peut donc voir `?ref=` sur n'importe quelle page d'arrivée |
| `event.cookies` | Stocke la première source pendant 30 jours. L'action la relit, sans input masqué à falsifier |
| Form actions avec `use:enhance` | Envoyer sans JavaScript et améliorer quand il se charge, avec un seul morceau de code serveur |
| La règle | Une page avec des actions ne peut pas être pré-rendue, car un serveur doit répondre à son `POST` |

Un input masqué ne peut pas faire cela. Il ne connaît que la page où se trouve le formulaire, et un tag dans l'URL disparaît après le premier clic. Un cookie défini côté serveur le conserve sur toutes les pages, et le visiteur ne le voit ni ne le modifie jamais.

C'est une attribution au premier contact : le premier lien tagué l'emporte. Une visite ultérieure depuis un autre lien ne l'écrase pas. Si vous préférez attribuer au dernier lien, retirez la vérification `!event.cookies.get(...)`.

Schéma : Le premier ?ref= d'un visiteur SvelteKit le suit jusqu'à l'inscription

1. Un visiteur arrive sur un article de blog depuis un lien avec ?ref=news.
2. Le hook handle voit le tag, ne trouve pas encore de cookie waitlist_ref et en définit un pour 30 jours.
3. Il lit les tarifs et revient à la page d'accueil. Aucune de ces URL n'a de tag ; le cookie, si.
4. Il s'inscrit. L'action join lit waitlist_ref et transmet l'inscription à Ikiform avec ref à news.
5. En cas de succès, l'action définit waitlist_joined : la page d'accueil affiche alors la phrase de remerciement à sa prochaine visite au lieu du formulaire.

La source vit dans un cookie httpOnly, pas dans le formulaire : elle survit donc à la navigation et ne peut pas être modifiée dans la page.

## Configurer le projet

Créez le formulaire et une clé secrète comme dans le [guide SvelteKit de la documentation](https://ikiform.com/fr/docs/guides/sveltekit.md). Cette page utilise SvelteKit 3. Les fichiers :

`Projet`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    +page.server.ts
    +page.svelte
    blog/[slug]/+page.svelte
vite.config.ts
.env
```

### Déclarer le secret

SvelteKit 3 a déprécié les modules `$env/...` au profit de variables déclarées. Déclarez la clé dans `src/env.ts` ; les variables sont privées sauf si vous les marquez publiques, et `$app/env/private` ne peut pas être importé dans du code navigateur.

`src/env.ts`:

```ts
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

L'action envoie `email` et `ref`. Si le formulaire a une définition de champs dans son onglet **Fields**, listez les deux :

`Champs`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## La construire

### Capturer la source

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";

const SOURCE = /^[a-z0-9_-]{1,40}$/;
const THIRTY_DAYS = 60 * 60 * 24 * 30;

export const handle: Handle = async ({ event, resolve }) => {
  const query = event.url.searchParams;
  const tag = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase();

  if (SOURCE.test(tag) && !event.cookies.get("waitlist_ref")) {
    event.cookies.set("waitlist_ref", tag, { maxAge: THIRTY_DAYS, httpOnly: true, sameSite: "lax" });
  }
  return resolve(event);
};
```

- Le tag est mis en minuscules et doit respecter le même motif que le `max_length` d'Ikiform autorise, pour qu'un lien forgé ne puisse pas stocker de déchets.
- `httpOnly` tient le cookie hors de portée des scripts de la page. `sameSite: "lax"` l'envoie quand même avec le `POST` du formulaire.
- Dans SvelteKit 3, le `path` d'un cookie vaut `/` par défaut : il atteint donc toutes les routes sans qu'on le définisse.

### L'action nommée

`src/routes/+page.server.ts`:

```ts
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions, PageServerLoad } from "./$types";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const YEAR = 60 * 60 * 24 * 365;

export const load: PageServerLoad = ({ cookies }) => ({
  joined: cookies.get("waitlist_joined") === "yes",
});

export const actions = {
  join: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const email = String(form.get("email") ?? "").trim();
    if (!EMAIL.test(email)) return fail(400, { email, message: "Enter the email we should send your invite to." });

    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({
        email,
        ref: cookies.get("waitlist_ref") ?? "direct",
        _gotcha: String(form.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    }).catch(() => null);

    if (!response) return fail(504, { email, message: "We couldn't reach the list. Try again." });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      return fail(response.status === 429 ? 429 : 400, {
        email,
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    }

    cookies.set("waitlist_joined", "yes", { maxAge: YEAR, httpOnly: true, sameSite: "lax" });
    return { joined: true };
  },
} satisfies Actions;
```

- Une action nommée, `join`, laisse de la place pour d'autres sur cette page plus tard. SvelteKit n'autorise pas une action par défaut à côté d'actions nommées.
- L'en-tête `Origin` du navigateur est transmis car Ikiform s'en sert pour compter les soumissions de test gratuites depuis `localhost`, et le `fetch` de l'action n'en envoie aucun.
- `fail()` renvoie l'e-mail saisi et un message. Dans SvelteKit 3, un envoi amélioré utilise le statut passé à `fail()` : `page.status` vaut donc `400` ou `429`.
- `load` lit `waitlist_joined` pour qu'un visiteur qui revient voie qu'il est déjà inscrit. C'est une politesse pour un navigateur, pas un dédoublonnage ; Ikiform stocke toujours une seconde inscription depuis un autre appareil.

### La page

`src/routes/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { data, form }: PageProps = $props();
  let pending = $state(false);
</script>

<h1>Acme is almost here</h1>

{#if data.joined || form?.joined}
  <p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
  <form
    method="POST"
    action="?/join"
    use:enhance={() => {
      pending = true;
      return async ({ update }) => {
        await update({ reset: false });
        pending = false;
      };
    }}
  >
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required value={form?.email ?? ""} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message}
      <p role="alert">{form.message}</p>
    {/if}
    <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
  </form>
{/if}
```

- `action="?/join"` nomme l'action. Sans JavaScript, le navigateur envoie là et SvelteKit affiche la page avec `form` défini.
- `use:enhance` envoie avec `fetch` une fois la page hydratée. `update({ reset: false })` garde l'e-mail dans l'input après une erreur, pour que le visiteur corrige une faute de frappe au lieu de tout retaper.
- `pending` désactive le bouton jusqu'à la réponse de l'action, ce qui empêche un double envoi sur un téléphone lent.
- `data.joined` vient de `load` et `form.joined` de l'action. Vérifier les deux couvre un visiteur qui revient et une nouvelle inscription avec une seule condition.

Le honeypot arrive tel quel chez Ikiform : les bots qui le remplissent sont donc stockés comme spam. Les envois avec la clé secrète contournent la vérification humaine d'Ikiform : activer **Spam check** n'apporte donc rien ici.

## Pré-rendu et déploiement

La page d'accueil a une action, elle doit donc être rendue côté serveur. Les articles de blog et autres contenus peuvent être pré-rendus avec `export const prerender = true`, mais `handle` ne s'exécute pas quand un visiteur ouvre un fichier pré-rendu ; il ne s'est exécuté qu'une fois au build. Un lien tagué menant directement à une page pré-rendue ne définit aucun cookie. Faites pointer les liens de campagne vers une page rendue côté serveur, comme la page d'accueil.

| Hébergeur | Adaptateur | Remarques |
| --- | --- | --- |
| Vercel | `@sveltejs/adapter-vercel` | SvelteKit 3 a abandonné ici le runtime `edge` : l'action tourne donc sur des fonctions Node |
| Netlify | `@sveltejs/adapter-netlify` | Définissez la clé sous **Environment variables** |
| Cloudflare | `@sveltejs/adapter-cloudflare` | Nécessite `wrangler` 4.118 ou plus récent, la peer dependency de l'adaptateur. Définissez la clé avec `wrangler secret put IKIFORM_SECRET_KEY` |

## Vérifiez que tout fonctionne

1. Lancez `vite dev`, ouvrez `http://localhost:5173/?ref=test`, puis retirez la query et rechargez. Le cookie `waitlist_ref` vaut toujours `test` dans les outils de développement du navigateur.
2. Inscrivez-vous. La phrase de remerciement remplace le formulaire sans rechargement, et l'inscription dans **Submissions** a `ref` à `test`.
3. Rechargez la page. La phrase de remerciement s'affiche, pas le formulaire.
4. Effacez les cookies, désactivez JavaScript et envoyez `a@b`. La page revient avec l'erreur et l'adresse toujours dans l'input.
5. Envoyez avec le honeypot rempli, `_gotcha=bot`. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook.
6. Les envois depuis `vite dev` portent une origine `localhost` : ils comptent donc comme des soumissions de test. 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).

## Pièges

| Problème | Que faire |
| --- | --- |
| Le build échoue en indiquant qu'une page avec des actions ne peut pas être pré-rendue | Un layout définit `prerender = true` pour toutes les routes. Mettez `export const prerender = false` dans le `+page.server.ts` de la page de liste d'attente. |
| `ref` vaut toujours `direct` | Le lien tagué pointait vers une page pré-rendue, donc `handle` ne l'a jamais vu, ou une visite précédente avait déjà défini le cookie. Vérifiez le cookie dans les outils de développement. |
| Avertissements `$env/static/private` après la mise à jour | SvelteKit 3 a déprécié les modules `$env`. Déclarez la clé dans `src/env.ts` et importez-la depuis `$app/env/private`. |
| `422` avec `unknown_field` | La définition des champs ne liste pas `ref`. Ajoutez-le comme dans la définition ci-dessus. |
| `402 plan_required` | L'espace de travail n'a pas d'offre et l'envoi venait d'un site en ligne. Il est mis en attente, pas perdu. Choisissez une offre ou achetez un pack sous **Settings › Plan and usage**, et les soumissions en attente arrivent. |

## Questions

### Comment suivre les sources UTM dans un formulaire SvelteKit ?

Lisez la query dans le hook handle à la première requête, stockez le tag dans un cookie httpOnly, et lisez le cookie dans la form action. La source survit à la navigation et n'apparaît jamais dans le formulaire.

### Une page SvelteKit avec une form action peut-elle être pré-rendue ?

Non. Une page avec des actions a besoin d'un serveur pour répondre à son POST : SvelteKit refuse donc de la pré-rendre. Pré-rendez les autres pages et gardez la page du formulaire rendue côté serveur.

### Le cookie waitlist_joined empêche-t-il les inscriptions en double ?

Seulement dans ce navigateur. Ikiform ne dédoublonne pas par e-mail : supprimez donc les répétitions là où vit la liste ou au moment de l'export.

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

- [Liste d'attente pour un nouveau SaaS](https://ikiform.com/fr/guides/waitlist.md): Créez une liste d'attente avant lancement sur votre landing page : le formulaire, l'anti-spam, les doublons, les e-mails de confirmation et un export propre le jour J.
- [Liste d'attente Next.js](https://ikiform.com/fr/guides/waitlist/nextjs.md): Une liste d'attente Next.js App Router : une server action qui transmet avec une clé secrète, useActionState, un merci optimiste et une limite par visiteur.
- [Liste d'attente Astro](https://ikiform.com/fr/guides/waitlist/astro.md): Une liste d'attente Astro sur une landing page statique : une Action transmet chaque inscription, un script l'appelle, une page à la demande gère le sans-JS.
- [Liste de lancement Product Hunt](https://ikiform.com/fr/guides/waitlist/product-hunt.md): Collectez une liste des semaines avant Product Hunt, taguez chaque inscription par canal et envoyez l'e-mail du jour J en deux vagues calées sur l'heure du Pacifique.
- [Inscription bêta avec sélection](https://ikiform.com/fr/guides/waitlist/beta-signup.md): Un formulaire de candidature bêta avec des questions de sélection appliquées côté serveur, et un script qui note les candidats et choisit chaque cohorte.
- [Liste d'attente d'application mobile](https://ikiform.com/fr/guides/waitlist/mobile-app.md): Une liste d'attente avant lancement pour une app iOS ou Android qui demande le téléphone utilisé, envoie les testeurs iPhone vers TestFlight et remplit un test fermé Play.
