Liste d'attente pour un nouveau SaaS

Liste d'attente pour un nouveau SaaS8 min de lecture

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.

Preet SutharFondateur, Ikiform

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 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.tsS'exécute avant chaque requête rendue côté serveur : il peut donc voir ?ref= sur n'importe quelle page d'arrivée
event.cookiesStocke la première source pendant 30 jours. L'action la relit, sans input masqué à falsifier
Form actions avec use:enhanceEnvoyer sans JavaScript et améliorer quand il se charge, avec un seul morceau de code serveur
La règleUne 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(...).

Le premier ?ref= d'un visiteur SvelteKit le suit jusqu'à l'inscriptionPremier jourInscription/blog/launch?ref=newspremière visitehook handledéfinitwaitlist_ref=news/pricing, puis /pas de query stringaction ?/joinlit waitlist_refIkiformref = newswaitlist_joinedla visite suivanteconfirmel'inscriptionrequêtenavigueJSONcookieLe premier ?ref= d'un visiteur SvelteKit le suit jusqu'à l'inscriptionPremier jourInscription/blog/launch?ref=newspremière visitehook handledéfinit waitlist_ref=news/pricing, puis /pas de query stringaction ?/joinlit waitlist_refIkiformref = newswaitlist_joinedla visite suivante confirmel'inscriptionrequêtenavigueJSONcookie
  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. Cette page utilise SvelteKit 3. Les fichiers :

Projet
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
import { defineEnvVars } from "@sveltejs/kit/env";

export const variables = defineEnvVars({
  IKIFORM_SECRET_KEY: {},
});
.env
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 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
{
  "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
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
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
<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ébergeurAdaptateurRemarques
Vercel@sveltejs/adapter-vercelSvelteKit 3 a abandonné ici le runtime edge : l'action tourne donc sur des fonctions Node
Netlify@sveltejs/adapter-netlifyDéfinissez la clé sous Environment variables
Cloudflare@sveltejs/adapter-cloudflareNé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.

Pièges

ProblèmeQue faire
Le build échoue en indiquant qu'une page avec des actions ne peut pas être pré-rendueUn 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 directLe 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 à jourSvelteKit 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_fieldLa définition des champs ne liste pas ref. Ajoutez-le comme dans la définition ci-dessus.
402 plan_requiredL'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

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