Warteliste für ein neues SaaS

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.

Preet SutharGründer, Ikiform

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:

FeatureWas es hier tut
handle in hooks.server.tsLäuft vor jedem serverseitig gerenderten Request, sieht also ?ref= auf jeder Seite, auf der der Besucher landet
event.cookiesSpeichert die erste Quelle 30 Tage lang. Die Action liest sie zurück, ohne verstecktes Eingabefeld, das man manipulieren könnte
Form-Actions mit use:enhanceOhne JavaScript senden und erweitern, wenn es lädt, mit einem Stück Servercode
Die RegelEine 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(...).

Das erste ?ref= eines SvelteKit-Besuchers folgt ihm bis zur AnmeldungTag einsAnmeldung/blog/launch?ref=newserster Besuchhandle-Hooksetztwaitlist_ref=news/pricing, dann /kein Query-StringAction ?/joinliest waitlist_refIkiformref = newswaitlist_joinedder nächste Besuchsagt: du bist dabeiRequeststöbertJSONCookieDas erste ?ref= eines SvelteKit-Besuchers folgt ihm bis zur AnmeldungTag einsAnmeldung/blog/launch?ref=newserster Besuchhandle-Hooksetzt waitlist_ref=news/pricing, dann /kein Query-StringAction ?/joinliest waitlist_refIkiformref = newswaitlist_joinedder nächste Besuch sagt: du bistdabeiRequeststöbertJSONCookie
  1. Ein Besucher landet über einen Link mit ?ref=news auf einem Blogpost.
  2. Der handle-Hook sieht den Tag, findet noch kein waitlist_ref-Cookie und setzt eines für 30 Tage.
  3. Er liest die Preisseite und kehrt zur Startseite zurück. Keine der URLs hat einen Tag; das Cookie schon.
  4. Er meldet sich an. Die join-Action liest waitlist_ref und leitet die Anmeldung mit ref auf news an Ikiform weiter.
  5. Bei Erfolg setzt die Action waitlist_joined, die Startseite zeigt beim nächsten Besuch also die Dankeszeile statt des Formulars.
Die Quelle lebt in einem httpOnly-Cookie, nicht im Formular, übersteht also die Navigation und lässt sich auf der Seite nicht bearbeiten.

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:

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

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

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
Die serverseitigen Versionen senden mit einem Secret Key, daher überspringt Ikiform Allowed domains und seine Menschlichkeitsprüfung. Jeder Post kommt dann von der IP deines Servers, und das Standard-Limit von 5 pro Minute und Besucher zählt alle deine Besucher zusammen. Wähle für ein Formular, das du über einen Server sendest, das Rate-Limit Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.

Die Action sendet email und ref. Wenn das Formular im Tab Fields eine Felddefinition hat, liste beide auf:

Felder
{
  "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

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);
};
  • Der Tag wird kleingeschrieben und muss demselben Muster entsprechen, das Ikiforms max_length erlaubt, ein präparierter Link kann also keinen Müll speichern.
  • httpOnly hält das Cookie von Seitenskripten fern. sameSite: "lax" sendet es trotzdem beim eigenen POST des Formulars mit.
  • In SvelteKit 3 ist der path eines Cookies standardmäßig /, es erreicht also jede Route, ohne dass du ihn setzt.

Die benannte Action

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;
  • 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 kostenlose localhost-Testeinsendungen daran zählt und das eigene fetch der 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 an fail() übergibst, page.status lautet also 400 oder 429.
  • load liest waitlist_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

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" benennt die Action. Ohne JavaScript sendet der Browser dorthin, und SvelteKit rendert die Seite mit gesetztem form.
  • use:enhance sendet mit fetch, 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.
  • pending deaktiviert den Button, bis die Action antwortet, was einen Doppel-Post auf einem langsamen Handy verhindert.
  • data.joined kommt aus load und form.joined aus 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.

HostAdapterHinweise
Vercel@sveltejs/adapter-vercelSvelteKit 3 hat hier die edge-Runtime entfernt, die Action läuft also auf Node-Funktionen
Netlify@sveltejs/adapter-netlifySetze den Schlüssel unter Environment variables
Cloudflare@sveltejs/adapter-cloudflareBraucht 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

  1. Führe vite dev aus, öffne http://localhost:5173/?ref=test, entferne dann die Query und lade neu. Das Cookie waitlist_ref ist in den Dev-Tools des Browsers weiterhin test.
  2. Melde dich an. Die Dankeszeile ersetzt das Formular ohne Neuladen, und die Anmeldung unter Submissions hat ref auf test.
  3. Lade die Seite neu. Die Dankeszeile erscheint, nicht das Formular.
  4. Lösche die Cookies, schalte JavaScript aus und sende a@b ab. Die Seite kommt mit dem Fehler zurück, und die Adresse steht noch im Eingabefeld.
  5. 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.
  6. Posts aus vite dev tragen einen localhost-Origin, zählen also als Testeinsendungen. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von localhost an. 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

ProblemWas zu tun ist
Der Build scheitert mit der Meldung, eine Seite mit Actions könne nicht vorgerendert werdenEin Layout setzt prerender = true für jede Route. Setze export const prerender = false in der +page.server.ts der Wartelisten-Seite.
ref ist immer directDer 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 UpgradeSvelteKit 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_fieldDie Felddefinition listet ref nicht auf. Füge es wie in der Definition oben hinzu.
402 plan_requiredDer 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

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.