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.
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.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(...).
- Un visiteur arrive sur un article de blog depuis un lien avec ?ref=news.
- Le hook handle voit le tag, ne trouve pas encore de cookie waitlist_ref et en définit un pour 30 jours.
- Il lit les tarifs et revient à la page d'accueil. Aucune de ces URL n'a de tag ; le cookie, si.
- Il s'inscrit. L'action join lit waitlist_ref et transmet l'inscription à Ikiform avec ref à news.
- 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.
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 :
src/
env.ts
hooks.server.ts
routes/
+page.server.ts
+page.svelte
blog/[slug]/+page.svelte
vite.config.ts
.envDé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.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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 :
{
"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
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_lengthd'Ikiform autorise, pour qu'un lien forgé ne puisse pas stocker de déchets. httpOnlytient le cookie hors de portée des scripts de la page.sameSite: "lax"l'envoie quand même avec lePOSTdu formulaire.- Dans SvelteKit 3, le
pathd'un cookie vaut/par défaut : il atteint donc toutes les routes sans qu'on le définisse.
L'action nommée
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
Origindu navigateur est transmis car Ikiform s'en sert pour compter les soumissions de test gratuites depuislocalhost, et lefetchde 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.statusvaut donc400ou429.loadlitwaitlist_joinedpour 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
<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 avecformdéfini.use:enhanceenvoie avecfetchune 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.pendingdésactive le bouton jusqu'à la réponse de l'action, ce qui empêche un double envoi sur un téléphone lent.data.joinedvient deloadetform.joinedde 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
- Lancez
vite dev, ouvrezhttp://localhost:5173/?ref=test, puis retirez la query et rechargez. Le cookiewaitlist_refvaut toujourstestdans les outils de développement du navigateur. - Inscrivez-vous. La phrase de remerciement remplace le formulaire sans rechargement, et l'inscription dans Submissions a
refàtest. - Rechargez la page. La phrase de remerciement s'affiche, pas le formulaire.
- Effacez les cookies, désactivez JavaScript et envoyez
a@b. La page revient avec l'erreur et l'adresse toujours dans l'input. - 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. - Les envois depuis
vite devportent une originelocalhost: 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 depuislocalhost. 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è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
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.
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.
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.
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.