Warteliste für ein neues SaaS7 Min. Lesezeit
Eine Warteliste in SvelteKit bauen
Eine SvelteKit-3-Warteliste: Ein Hook behält das erste ?ref= in einem Cookie, eine benannte Form-Action leitet die Anmeldung weiter, und wiederkehrende Besucher sehen, dass sie dabei sind.
Lies ?ref= beim ersten Request des Besuchers in hooks.server.ts und behalte es in einem Cookie, damit die Anmeldung dem Link zugerechnet wird, der ihn gebracht hat, auch nachdem er herumgestöbert hat. Eine benannte Form-Action liest das Cookie, leitet die Anmeldung mit einem Secret Key an dein Formular-Backend weiter und setzt ein zweites Cookie, damit die Seite einen wiederkehrenden Besucher begrüßt, statt erneut zu fragen.
Die Hauptanleitung hat einen SvelteKit-Tab: eine Default-Action und use:enhance, mit ref fest auf homepage. Diese Seite fügt hinzu, was ein echter Launch braucht. Besucher kommen über einen Newsletter auf einen Blogpost, lesen eine Weile und melden sich zwei Seiten später an. Du willst diese Anmeldung dem Newsletter zurechnen, nicht der Seite, auf der sie ihre E-Mail eingegeben haben.
Was in SvelteKit anders ist
Zwei SvelteKit-Features passen gut dazu, und eine Regel prägt das Setup:
| Feature | Was es hier tut |
|---|---|
handle in hooks.server.ts | Läuft vor jedem serverseitig gerenderten Request, sieht also ?ref= auf jeder Seite, auf der der Besucher landet |
event.cookies | Speichert die erste Quelle 30 Tage lang. Die Action liest sie zurück, ohne verstecktes Eingabefeld, das man manipulieren könnte |
Form-Actions mit use:enhance | Ohne JavaScript senden und erweitern, wenn es lädt, mit einem Stück Servercode |
| Die Regel | Eine Seite mit Actions kann nicht vorgerendert werden, weil ein Server ihren POST beantworten muss |
Ein verstecktes Eingabefeld kann das nicht. Es kennt nur die Seite, auf der das Formular steht, und ein Tag in der URL ist nach dem ersten Klick weg. Ein auf dem Server gesetztes Cookie behält es über jede Seite hinweg, und der Besucher sieht oder bearbeitet es nie.
Das ist First-Touch-Attribution: Der erste getaggte Link gewinnt. Ein späterer Besuch über einen anderen Link überschreibt ihn nicht. Wenn du lieber den letzten Link zählen willst, entferne die Prüfung !event.cookies.get(...).
- Ein Besucher landet über einen Link mit ?ref=news auf einem Blogpost.
- Der handle-Hook sieht den Tag, findet noch kein waitlist_ref-Cookie und setzt eines für 30 Tage.
- Er liest die Preisseite und kehrt zur Startseite zurück. Keine der URLs hat einen Tag; das Cookie schon.
- Er meldet sich an. Die join-Action liest waitlist_ref und leitet die Anmeldung mit ref auf news an Ikiform weiter.
- Bei Erfolg setzt die Action waitlist_joined, die Startseite zeigt beim nächsten Besuch also die Dankeszeile statt des Formulars.
Das Projekt einrichten
Erstelle das Formular und einen Secret Key wie in der SvelteKit-Anleitung in der Doku. Diese Seite nutzt SvelteKit 3. Die Dateien:
src/
env.ts
hooks.server.ts
routes/
+page.server.ts
+page.svelte
blog/[slug]/+page.svelte
vite.config.ts
.envDas Secret deklarieren
SvelteKit 3 hat die $env/...-Module zugunsten deklarierter Variablen als veraltet markiert. Deklariere den Schlüssel in src/env.ts; Variablen sind privat, außer du markierst sie als öffentlich, und $app/env/private kann nicht in Browser-Code importiert werden.
import { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
IKIFORM_SECRET_KEY: {},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Action sendet email und ref. Wenn das Formular im Tab Fields eine Felddefinition hat, liste beide auf:
{
"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 } }
]
}Bauen
Die Quelle erfassen
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);
};- Der Tag wird kleingeschrieben und muss demselben Muster entsprechen, das Ikiforms
max_lengtherlaubt, ein präparierter Link kann also keinen Müll speichern. httpOnlyhält das Cookie von Seitenskripten fern.sameSite: "lax"sendet es trotzdem beim eigenenPOSTdes Formulars mit.- In SvelteKit 3 ist der
patheines Cookies standardmäßig/, es erreicht also jede Route, ohne dass du ihn setzt.
Die benannte Action
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;- Eine benannte Action,
join, lässt später Platz für weitere auf dieser Seite. SvelteKit erlaubt keine Default-Action neben benannten. - Der
Origin-Header des Browsers wird weitergereicht, weil Ikiform kostenloselocalhost-Testeinsendungen daran zählt und das eigenefetchder Action keinen sendet. fail()gibt die eingegebene E-Mail und eine Meldung zurück. In SvelteKit 3 nutzt ein erweitertes Absenden den Status, den du anfail()übergibst,page.statuslautet also400oder429.loadliestwaitlist_joined, damit ein wiederkehrender Besucher sieht, dass er schon dabei ist. Das ist eine Höflichkeit für einen Browser, keine Deduplizierung; Ikiform speichert trotzdem eine zweite Anmeldung von einem anderen Gerät.
Die Seite
<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"benennt die Action. Ohne JavaScript sendet der Browser dorthin, und SvelteKit rendert die Seite mit gesetztemform.use:enhancesendet mitfetch, sobald die Seite hydriert ist.update({ reset: false })behält die E-Mail nach einem Fehler im Eingabefeld, der Besucher korrigiert also einen Tippfehler, statt alles neu zu tippen.pendingdeaktiviert den Button, bis die Action antwortet, was einen Doppel-Post auf einem langsamen Handy verhindert.data.joinedkommt ausloadundform.joinedaus der Action. Beides zu prüfen deckt mit einer Bedingung einen wiederkehrenden Besucher und eine frische Anmeldung ab.
Der Honeypot erreicht Ikiform unverändert, Bots, die ihn ausfüllen, werden also als Spam gespeichert. Posts mit dem Secret Key überspringen Ikiforms Menschlichkeitsprüfung, Spam check zu aktivieren bringt hier also nichts.
Prerendering und Deployment
Die Startseite hat eine Action, muss also auf dem Server rendern. Blogposts und andere Inhalte können mit export const prerender = true vorgerendert werden, aber handle läuft nicht, wenn ein Besucher eine vorgerenderte Datei öffnet; es lief einmal beim Build. Ein getaggter Link direkt auf eine vorgerenderte Seite setzt kein Cookie. Richte Kampagnenlinks auf eine serverseitig gerenderte Seite aus, etwa die Startseite.
| Host | Adapter | Hinweise |
|---|---|---|
| Vercel | @sveltejs/adapter-vercel | SvelteKit 3 hat hier die edge-Runtime entfernt, die Action läuft also auf Node-Funktionen |
| Netlify | @sveltejs/adapter-netlify | Setze den Schlüssel unter Environment variables |
| Cloudflare | @sveltejs/adapter-cloudflare | Braucht wrangler 4.118 oder neuer, die Peer-Dependency des Adapters. Setze den Schlüssel mit wrangler secret put IKIFORM_SECRET_KEY |
Prüfen, ob es funktioniert
- Führe
vite devaus, öffnehttp://localhost:5173/?ref=test, entferne dann die Query und lade neu. Das Cookiewaitlist_refist in den Dev-Tools des Browsers weiterhintest. - Melde dich an. Die Dankeszeile ersetzt das Formular ohne Neuladen, und die Anmeldung unter Submissions hat
refauftest. - Lade die Seite neu. Die Dankeszeile erscheint, nicht das Formular.
- Lösche die Cookies, schalte JavaScript aus und sende
a@bab. Die Seite kommt mit dem Fehler zurück, und die Adresse steht noch im Eingabefeld. - Sende einen Post mit ausgefülltem Honeypot,
_gotcha=bot. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus. - Posts aus
vite devtragen einenlocalhost-Origin, zählen also als Testeinsendungen. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat vonlocalhostan. Für den Livebetrieb wähle Pro ab $6/Monat für 500 Einsendungen oder zahle einmalig: 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt. Siehe Preise.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
| Der Build scheitert mit der Meldung, eine Seite mit Actions könne nicht vorgerendert werden | Ein Layout setzt prerender = true für jede Route. Setze export const prerender = false in der +page.server.ts der Wartelisten-Seite. |
ref ist immer direct | Der getaggte Link zeigte auf eine vorgerenderte Seite, handle hat ihn also nie gesehen, oder ein früherer Besuch hat das Cookie schon gesetzt. Prüfe das Cookie in den Dev-Tools. |
$env/static/private-Warnungen nach dem Upgrade | SvelteKit 3 hat die $env-Module als veraltet markiert. Deklariere den Schlüssel in src/env.ts und importiere ihn aus $app/env/private. |
422 mit unknown_field | Die Felddefinition listet ref nicht auf. Füge es wie in der Definition oben hinzu. |
402 plan_required | Der Workspace hat keinen Tarif, und der Post kam von einer Live-Site. Er wird zurückgehalten, nicht verloren. Wähle einen Tarif oder kauf ein Paket unter Settings › Plan and usage, und zurückgehaltene Einsendungen kommen an. |
Fragen
Lies die Query beim ersten Request im handle-Hook, speichere den Tag in einem httpOnly-Cookie und lies das Cookie in der Form-Action. Die Quelle übersteht die Navigation und erscheint nie im Formular.
Nein. Eine Seite mit Actions braucht einen Server, der ihren POST beantwortet, SvelteKit weigert sich also, sie vorzurendern. Rendere die anderen Seiten vor und lass die Formularseite serverseitig gerendert.
Nur in diesem Browser. Ikiform dedupliziert nicht nach E-Mail, entferne Wiederholungen also dort, wo die Liste lebt, oder beim Export.
Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.