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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/waitlist/sveltekit

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](https://ikiform.com/de/guides/waitlist.md#form) 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(...)`.

Diagramm: Das erste ?ref= eines SvelteKit-Besuchers folgt ihm bis zur Anmeldung

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](https://ikiform.com/de/docs/guides/sveltekit.md). Diese Seite nutzt SvelteKit 3. Die Dateien:

`Projekt`:

```text
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`:

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

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

`.env`:

```bash
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](https://ikiform.com/de/docs/rate-limits.md) 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`:

```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 } }
  ]
}
```

## Bauen

### Die Quelle erfassen

`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);
};
```

- 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`:

```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`:

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

| 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

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](https://ikiform.com/de/pricing).

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

### Wie tracke ich UTM-Quellen in einem SvelteKit-Formular?

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.

### Kann eine SvelteKit-Seite mit einer Form-Action vorgerendert werden?

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.

### Verhindert das Cookie waitlist_joined doppelte Anmeldungen?

Nur in diesem Browser. Ikiform dedupliziert nicht nach E-Mail, entferne Wiederholungen also dort, wo die Liste lebt, oder beim Export.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Warteliste für ein neues SaaS](https://ikiform.com/de/guides/waitlist.md): Baue eine Pre-Launch-Warteliste auf deiner eigenen Landingpage: das Formular, Spamschutz, Duplikate, Bestätigungs-E-Mails und ein sauberer Export am Launch-Tag.
- [Warteliste mit Next.js](https://ikiform.com/de/guides/waitlist/nextjs.md): Eine Warteliste mit dem Next.js App Router: eine Server Action, die Anmeldungen mit Secret Key weiterleitet, useActionState, ein optimistisches Danke und ein Limit pro Besucher.
- [Astro-Warteliste](https://ikiform.com/de/guides/waitlist/astro.md): Eine Astro-Warteliste auf einer statischen Landingpage: Eine Action leitet jede Anmeldung weiter, ein Skript ruft sie auf, und eine On-Demand-Seite fängt Posts ohne JavaScript ab.
- [Launch-Liste für Product Hunt](https://ikiform.com/de/guides/waitlist/product-hunt.md): Sammle Wochen vor Product Hunt eine Launch-Liste, markiere jede Anmeldung nach Kanal und sende die Launch-E-Mail in zwei Wellen, abgestimmt auf den Reset nach Pacific Time.
- [Beta-Anmeldung mit Auswahl](https://ikiform.com/de/guides/waitlist/beta-signup.md): Ein Beta-Bewerbungsformular mit Auswahlfragen, die der Server durchsetzt, und ein Skript, das Bewerber bewertet und jede Kohorte aus der Liste auswählt.
- [Warteliste für mobile Apps](https://ikiform.com/de/guides/waitlist/mobile-app.md): Eine Pre-Launch-Warteliste für eine iOS- oder Android-App, die fragt, welches Handy jemand nutzt, iPhone-Tester zu TestFlight schickt und einen Play-Closed-Test füllt.
