Formular zur Lead-Erfassung

Formular zur Lead-Erfassung7 Min. Lesezeit

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.

Preet SutharGründer, Ikiform

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

Was SvelteKit mit jeder Art von Action-Ergebnis machtjedes GETabsendenhandle-Hooknoch keinCookie?cookies.setlead_source,httpOnlyresolve(event)rendert dieSeiteuse:enhancefetch-POSTStandard-ActionValibot, dannIkiformredirect(303)anderer Origin:volles Ladenform-PropFehler undWerte, nichtzurückgesetztTags oder ReferrerFormulardatenfail(400)Was SvelteKit mit jeder Art von Action-Ergebnis machtjedes GETabsendenhandle-Hooknoch kein Cookie?cookies.setlead_source,httpOnlyresolve(event)rendert die Seiteuse:enhancefetch-POSTStandard-ActionValibot, dannIkiformredirect(303)anderer Origin:volles Ladenform-PropFehler undWerte,nichtzurückgesetztTags oder ReferrerFormulardatenfail(400)
  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 behandelt Key und Formular.

Dateien
src/
  env.ts
  hooks.server.ts
  routes/
    contact-sales/
      +page.server.ts
      +page.svelte
      thanks/+page.svelte
vite.config.ts
.env
src/env.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-IDTyp in IkiformGesetzt von
name, email, companyText, E-Mail, TextDem Besucher
company_sizeAuswahl: 1_10, 11_50, 51_200, 201_1000, 1000_plusDem Besucher
budgetAuswahl: under_10k, 10k_50k, 50k_plus, unsureDem Besucher
timelineAuswahl: this_quarter, next_quarter, later, researchingDem Besucher
messageTextarea, optionalDem Besucher
utm_source, utm_medium, utm_campaign, referrer, landing_pageText, optionalDeinem Server, aus dem ersten Besuch
fitSelect: qualified, nurtureDeinem 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 Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.

Der erste Kontakt im handle-Hook

src/hooks.server.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
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
<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.

Hinweise zum Deployment

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

  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

ProblemWas zu tun ist
403 Cross-site POST form submissions are forbiddenSvelteKits 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 URLSvelteKit 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 FehlerEin 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 leerupdate() wurde ohne reset: false aufgerufen, oder die Inputs lesen form.values nicht.
422 validation_failedDer 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

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