# Qualifizierendes Lead-Formular mit SvelteKit bauen

> Ein Lead-Formular mit SvelteKit: Der handle-Hook hält UTMs des ersten Kontakts fest, eine Form Action validiert mit Valibot, und redirect() schickt qualifizierte Leads in einen Kalender.

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

Halte UTM-Tags und Referrer beim ersten GET im `handle`-Hook in einem httpOnly-Cookie fest. Validiere den Lead in einer Form Action mit Valibot, gib `fail()` mit Fehlern und Werten zurück, wenn er nicht besteht, und leite gültige Leads mit der Attribution an dein Formular-Backend weiter. Ruf dann `redirect(303, url, { external: true })` auf, um qualifizierte Leads zu deinem Terminplaner zu schicken; `use:enhance` folgt dem auf die andere Site.

Der [SvelteKit-Tab der Hauptanleitung](https://ikiform.com/de/guides/lead-capture.md#form) leitet einen Lead aus einer Form Action weiter und zeigt eine Dankeszeile. Diese Seite baut die Version, nach der ein Vertriebsteam fragt: Unternehmensgröße, Budget und Zeitplan, auf dem Server validiert, die Kampagne hinter jedem Lead und eine Übergabe an deinen Kalender, wenn der Lead passt.

## Was SvelteKit ändert

Form Actions sind SvelteKits nativer Weg, einen Post zu verarbeiten, und sie fallen sauber zurück: Ohne JavaScript sendet der Browser und folgt Weiterleitungen selbst, und mit `use:enhance` läuft dieselbe Action über `fetch`. Ein Redirect-Ergebnis auf einen anderen Origin fällt auf ein vollständiges Seitenladen zurück, sodass die Übergabe an Cal.com keinen zusätzlichen Client-Code braucht.

SvelteKit gibt dir außerdem einen Hook, `handle`, der jeden Server-Request umschließt. Er ist der richtige Ort, um den ersten Kontakt festzuhalten. SvelteKit 3 hat zwei Dinge geändert, die dieses Formular betreffen: Eine Weiterleitung auf eine andere Site braucht `{ external: true }`, und Secrets werden in `src/env.ts` deklariert und zur Laufzeit aus `$app/env/private` gelesen.

Diagramm: Was SvelteKit mit jeder Art von Action-Ergebnis macht

1. Der handle-Hook sieht jedes GET. Gibt es noch kein lead_source-Cookie und einen UTM-Tag oder externen Referrer, setzt er eins.
2. SvelteKit löst den Request auf und rendert die Seite wie gewohnt.
3. Beim Absenden sendet use:enhance das Formular per fetch und setzt den Pending-Zustand.
4. Die Action validiert mit Valibot, liest das Cookie und leitet den Lead mit dem Origin des Besuchers weiter.
5. Ein Redirect zu Cal.com kann keine Client-Navigation sein, also lädt SvelteKit ihn als vollständige Seite.
6. Ein fail-Ergebnis aktualisiert die form-Prop; update mit reset false behält die Inputs wie eingetippt.

Ohne JavaScript macht der Browser dieselben zwei Dinge selbst: Er folgt dem 303 oder rendert die Seite mit form.

## Projekt-Setup

Starte mit einem SvelteKit-3-Projekt, installiere `valibot` und füge deinen Adapter hinzu. Leg den Secret Key als `IKIFORM_SECRET_KEY` in `.env` ab und deklariere ihn in `src/env.ts`. Er ist standardmäßig privat und wird zur Laufzeit gelesen, und SvelteKit weigert sich, `$app/env/private` in Client-Code zu importieren. Die [SvelteKit-Seite der Doku](https://ikiform.com/de/docs/guides/sveltekit.md) behandelt Key und Formular.

`Dateien`:

```text
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
```

`src/env.ts`:

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

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

Füge die Felder im Tab **Fields** des Formulars hinzu, damit Ikiform die Prüfungen wiederholt und Felder ablehnt, die du nicht definiert hast.

| Feld-ID | Typ in Ikiform | Gesetzt von |
| --- | --- | --- |
| `name`, `email`, `company` | Text, E-Mail, Text | Dem Besucher |
| `company_size` | Auswahl: `1_10`, `11_50`, `51_200`, `201_1000`, `1000_plus` | Dem Besucher |
| `budget` | Auswahl: `under_10k`, `10k_50k`, `50k_plus`, `unsure` | Dem Besucher |
| `timeline` | Auswahl: `this_quarter`, `next_quarter`, `later`, `researching` | Dem Besucher |
| `message` | Textarea, optional | Dem Besucher |
| `utm_source`, `utm_medium`, `utm_campaign`, `referrer`, `landing_page` | Text, optional | Deinem Server, aus dem ersten Besuch |
| `fit` | Select: `qualified`, `nurture` | Deinem Server, nach der Validierung |

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

## Der erste Kontakt im handle-Hook

`src/hooks.server.ts`:

```ts
import type { Handle } from "@sveltejs/kit/hooks";

const TAGS = ["utm_source", "utm_medium", "utm_campaign"];

export const handle: Handle = async ({ event, resolve }) => {
  if (event.request.method === "GET" && !event.cookies.get("lead_source")) {
    const referer = event.request.headers.get("referer") ?? "";
    const from = URL.canParse(referer) ? new URL(referer) : null;
    const external = from && from.host !== event.url.host ? from.href.slice(0, 300) : "";
    const source = new URLSearchParams();
    for (const tag of TAGS) source.set(tag, event.url.searchParams.get(tag)?.slice(0, 100) ?? "");

    if (external || TAGS.some((tag) => source.get(tag))) {
      source.set("referrer", external);
      source.set("landing_page", event.url.pathname.slice(0, 200));
      event.cookies.set("lead_source", source.toString(), {
        path: "/",
        httpOnly: true,
        sameSite: "lax",
        maxAge: 60 * 60 * 24 * 30,
      });
    }
  }
  return resolve(event);
};
```

- Nur GET-Requests zählen, also setzt der eigene POST der Action das Cookie nie.
- SvelteKit 3 setzt den `path` eines Cookies standardmäßig auf `/`. Ihn explizit zu setzen, lässt das Beispiel auch auf SvelteKit 2 funktionieren, wo `path` Pflicht ist.
- Ein Referrer von deinem eigenen Host wird ignoriert, sodass interne Klicks die echte Quelle nicht ersetzen.
- Der Hook läuft nicht für vorgerenderte Seiten, die als Dateien ausgeliefert werden. Lass Kampagnen-Landingpages on demand rendern, sonst erreichen ihn die Tags nie.

## Die Form-Action

`src/routes/contact-sales/+page.server.ts`:

```ts
import { fail, redirect } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import * as v from "valibot";
import type { Actions } from "./$types";

export const prerender = false;

const Lead = v.object({
  name: v.pipe(v.string(), v.trim(), v.nonEmpty("Tell us who to reply to."), v.maxLength(120)),
  email: v.pipe(v.string(), v.trim(), v.email("Enter a work email we can reply to.")),
  company: v.pipe(v.string(), v.trim(), v.nonEmpty("Which company is this for?"), v.maxLength(200)),
  company_size: v.picklist(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], "Pick a company size."),
  budget: v.picklist(["under_10k", "10k_50k", "50k_plus", "unsure"], "Pick a budget range."),
  timeline: v.picklist(["this_quarter", "next_quarter", "later", "researching"], "Pick a timeline."),
  message: v.pipe(v.string(), v.trim(), v.maxLength(5000, "Keep it under 5,000 characters.")),
  _gotcha: v.string(),
});

const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];

export const actions = {
  default: async ({ request, cookies, fetch, url }) => {
    const form = await request.formData();
    const values = Object.fromEntries(Object.keys(Lead.entries).map((key) => [key, String(form.get(key) ?? "")]));
    const parsed = v.safeParse(Lead, values);
    if (!parsed.success) {
      const nested = v.flatten<typeof Lead>(parsed.issues).nested ?? {};
      const errors = Object.fromEntries(Object.entries(nested).map(([key, list]) => [key, list?.[0] ?? ""]));
      return fail(400, { errors, values });
    }

    const lead = parsed.output;
    const stored = new URLSearchParams(cookies.get("lead_source") ?? "");
    const qualified =
      lead.company_size !== "1_10" &&
      (lead.budget === "10k_50k" || lead.budget === "50k_plus") &&
      (lead.timeline === "this_quarter" || lead.timeline === "next_quarter");

    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({
        ...lead,
        ...Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""])),
        fit: qualified ? "qualified" : "nurture",
      }),
    });
    const result = await response.json().catch(() => null);
    if (!result?.ok) {
      const fields: Record<string, string[]> = result?.error?.fields ?? {};
      const errors = Object.fromEntries(Object.entries(fields).map(([key, list]) => [key, list[0] ?? ""]));
      if (!Object.keys(errors).length) errors.form = "We couldn't send that. Email sales@example.com instead.";
      return fail(response.status >= 400 ? response.status : 502, { errors, values });
    }

    if (!qualified || lead._gotcha) redirect(303, "/contact-sales/thanks");
    redirect(303, `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}`, { external: true });
  },
} satisfies Actions;
```

- Valibots `picklist` lehnt ein leeres `select` mit deiner Meldung ab, und `flatten` liefert eine Liste von Meldungen pro Feld.
- `fail()` gibt auch die Werte zurück, weil die Seite nach einem nativen Post keine andere Kopie davon hat. SvelteKit 3 sendet den Status von `fail` als HTTP-Status.
- Der `Origin`-Header des Browsers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform daran kostenlose `localhost`-Tests erkennt.
- `redirect()` wirft eine Exception, also läuft danach nichts mehr. Seit SvelteKit 2 rufst du es ohne `throw` auf, und seit SvelteKit 3 braucht ein Ziel auf einem anderen Origin `{ external: true }`, sonst scheitert die Action, statt weiterzuleiten.
- Seiten mit Actions können nicht vorgerendert werden. `prerender = false` macht das explizit, falls ein übergeordnetes Layout Prerendering einschaltet.

## Die Seite

`src/routes/contact-sales/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let pending = $state(false);
  const errors = $derived<Record<string, string | undefined>>(form?.errors ?? {});
  const values = $derived<Record<string, string | undefined>>(form?.values ?? {});

  const selects = [
    { id: "company_size", label: "Company size", options: [["1_10", "1 to 10"], ["11_50", "11 to 50"], ["51_200", "51 to 200"], ["201_1000", "201 to 1,000"], ["1000_plus", "More than 1,000"]] },
    { id: "budget", label: "Budget", options: [["under_10k", "Under $10k"], ["10k_50k", "$10k to $50k"], ["50k_plus", "More than $50k"], ["unsure", "Not sure yet"]] },
    { id: "timeline", label: "When do you want to start?", options: [["this_quarter", "This quarter"], ["next_quarter", "Next quarter"], ["later", "Later this year"], ["researching", "Researching for now"]] },
  ];
</script>

<form
  method="POST"
  use:enhance={() => {
    pending = true;
    return async ({ update }) => {
      await update({ reset: false });
      pending = false;
    };
  }}
>
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required value={values.name ?? ""} />
  {#if errors.name}<p role="alert">{errors.name}</p>{/if}

  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required value={values.email ?? ""} />
  {#if errors.email}<p role="alert">{errors.email}</p>{/if}

  <label for="company">Company</label>
  <input id="company" name="company" autocomplete="organization" required value={values.company ?? ""} />
  {#if errors.company}<p role="alert">{errors.company}</p>{/if}

  {#each selects as select (select.id)}
    <label for={select.id}>{select.label}</label>
    <select id={select.id} name={select.id} required value={values[select.id] ?? ""}>
      <option value="" disabled>Choose one</option>
      {#each select.options as [value, text] (value)}
        <option {value}>{text}</option>
      {/each}
    </select>
    {#if errors[select.id]}<p role="alert">{errors[select.id]}</p>{/if}
  {/each}

  <label for="message">Anything we should know?</label>
  <textarea id="message" name="message" rows="4">{values.message ?? ""}</textarea>

  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  {#if errors.form}<p role="alert">{errors.form}</p>{/if}
  <button disabled={pending}>{pending ? "Sending" : "Talk to sales"}</button>
</form>
```

`use:enhance` führt das Standardverhalten über `update()` aus, aber `reset: false` behält die Inputs nach einem Fehler. Ohne JavaScript kommen die `fail`-Daten der Action in der Prop `form` zurück, und die `value`-Attribute füllen jedes Feld wieder.

### Honeypot und Menschlichkeitsprüfung

Der Honeypot steht im Schema, erreicht also Ikiform, und ein ausgefüllter wird als Spam gespeichert; die Action schickt ihn auf die Dankeseite. SvelteKits eingebaute CSRF-Prüfung lehnt Formular-Posts von anderen Origins bereits ab. Die Menschlichkeitsprüfung von Ikiform wird mit dem Secret Key übersprungen, also verifiziere bei Bedarf ein Turnstile-Token in der Action, wie in [Turnstile in einem Formular](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md).

## Hinweise zum Deployment

| Host | Gut zu wissen |
| --- | --- |
| Vercel | `@sveltejs/adapter-vercel` oder `adapter-auto`. Füge `IKIFORM_SECRET_KEY` in den Umgebungsvariablen des Projekts hinzu. |
| Netlify | `@sveltejs/adapter-netlify`. Setze den Key in den Einstellungen der Site. |
| Cloudflare | `@sveltejs/adapter-cloudflare`. Speichere den Key mit `wrangler secret put`. Er existiert nur zur Laufzeit, was `$app/env/private` abdeckt, weil deklarierte Variablen beim Start der App gelesen werden. |

## Prüfen, ob es funktioniert

Posts aus `vite dev` tragen einen `localhost`-Origin. 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).

1. Starte `vite dev`, öffne `/?utm_source=test&utm_medium=email` und prüf das Cookie `lead_source` in den Entwicklertools.
2. Wähle auf `/contact-sales` eine Unternehmensgröße, lass **Budget** leer und sende ab. Der Budget-Fehler erscheint, und deine anderen Antworten bleiben.
3. Sende als qualifizierter Lead: 201 bis 1.000 Personen, $10k bis $50k, dieses Quartal. Die Seite lädt Cal.com mit ausgefülltem Namen und E-Mail.
4. Sende mit **Later this year**. Du landest auf `/contact-sales/thanks`.
5. Öffne **Submissions** im Dashboard und prüf `utm_medium`, `landing_page` und `fit`.
6. Prüfe das Postfach jedes Benachrichtigungsempfängers. Jeder bekommt eine E-Mail mit den Feldern, und **Reply** geht an den Absender, wenn das Formular ein `email`-Feld hat.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| `403 Cross-site POST form submissions are forbidden` | SvelteKits CSRF-Prüfung hat einen Origin gesehen, der nicht passt. Hinter einem Proxy, der den Host nicht weitergibt, setze `paths.origin` in den Optionen von `sveltekit()` in `vite.config.ts`; SvelteKit 3 hat die Variable `ORIGIN` von adapter-node entfernt. Für eine andere Site von dir füge sie zu `csrf.trustedOrigins` hinzu. |
| Die Weiterleitung zu Cal.com scheitert mit einem Fehler zu einer externen URL | SvelteKit 3 lehnt externe Weiterleitungen standardmäßig ab. Übergib `{ external: true }` oder eine Allowlist wie `{ external: ["https://cal.com"] }` als drittes Argument. |
| Die Seite wechselt nicht zu Cal.com, und die Konsole zeigt einen Fehler | Ein eigener `use:enhance`-Callback hat beim Redirect `goto()` aufgerufen. `goto` lehnt externe URLs ab; ruf stattdessen `update()` oder `applyAction()` auf. |
| Felder werden nach einem Fehler leer | `update()` wurde ohne `reset: false` aufgerufen, oder die Inputs lesen `form.values` nicht. |
| `422 validation_failed` | Der Post passt nicht zur Felddefinition des Formulars. `error.fields` nennt jedes Feld und den Grund. Gleiche den `name` des Eingabefelds an die Feld-ID an. |

## Fragen

### Kann eine SvelteKit Form Action auf eine externe URL weiterleiten?

Ja. Ruf in der Action redirect(303, 'https://...', { external: true }) auf; SvelteKit 3 lehnt ein externes Ziel ohne diese Option ab. Ohne JavaScript folgt der Browser, und mit use:enhance fällt SvelteKit auf ein vollständiges Seitenladen zurück, weil die URL nicht zu deiner App gehört.

### Wie lese ich den Secret Key in SvelteKit 3?

Deklariere ihn in src/env.ts mit defineEnvVars und importiere ihn aus $app/env/private. Er wird zur Laufzeit gelesen, außer du setzt static: true, also funktionieren Secrets, die es nur zur Laufzeit gibt, etwa Cloudflare-Workers-Secrets. Die $env-Module funktionieren noch, sind aber veraltet.

### Wie behalte ich Formularwerte nach einer fehlgeschlagenen SvelteKit-Action?

Gib sie in fail(400, { errors, values }) zurück und binde den Wert jedes Inputs an form.values. Ruf mit use:enhance update({ reset: false }) auf, damit das Formular nicht geleert wird.

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

- [Formular zur Lead-Erfassung](https://ikiform.com/de/guides/lead-capture.md): Ein Lead-Formular auf deiner eigenen Site, das dich per E-Mail mit Reply-To benachrichtigt, den Team-Chat anpingt und dein CRM füttert, damit du antwortest, solange der Lead noch da ist.
- [Lead-Formular mit Nuxt](https://ikiform.com/de/guides/lead-capture/nuxt.md): Ein Lead-Formular mit Nuxt: Server-Middleware hält UTMs des ersten Kontakts fest, eine mit Zod validierte Server-Route bedient fetch und einfache Posts, und gute Leads buchen einen Termin.
- [Lead-Formular mit Astro](https://ikiform.com/de/guides/lead-capture/astro.md): Ein Lead-Formular auf Basis von Astro Actions: Validierung mit Zod, UTMs des ersten Kontakts auf einer statischen Site, eine On-Demand-Kontaktseite und eine Weiterleitung zu deinem Terminplaner.
- [Formular für Demo-Anfragen](https://ikiform.com/de/guides/lead-capture/demo-request.md): Ein Formular zum Demo-Buchen, das den Lead speichert und den Besucher dann mit vorausgefülltem Namen und E-Mail zu Cal.com oder Calendly schickt.
- [Formular für Angebotsanfragen](https://ikiform.com/de/guides/lead-capture/quote-request.md): Ein Formular für Angebotsanfragen, das Briefings als geteilte Links statt als Uploads sammelt, jeder Anfrage ein Fälligkeitsdatum gibt und linklastige Leads aus dem Spam hält.
- [Formular für Beratungsanfragen](https://ikiform.com/de/guides/lead-capture/consultation-request.md): Ein Formular für Beratungsanfragen für Steuerberater, Anwälte, Coaches und Praxen, das fragt, wie und wann angerufen werden soll, und heikle Details für das Gespräch lässt.
