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.
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.
- Der handle-Hook sieht jedes GET. Gibt es noch kein lead_source-Cookie und einen UTM-Tag oder externen Referrer, setzt er eins.
- SvelteKit löst den Request auf und rendert die Seite wie gewohnt.
- Beim Absenden sendet use:enhance das Formular per fetch und setzt den Pending-Zustand.
- Die Action validiert mit Valibot, liest das Cookie und leitet den Lead mit dem Origin des Besuchers weiter.
- Ein Redirect zu Cal.com kann keine Client-Navigation sein, also lädt SvelteKit ihn als vollständige Seite.
- Ein fail-Ergebnis aktualisiert die form-Prop; update mit reset false behält die Inputs wie eingetippt.
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.
src/
env.ts
hooks.server.ts
routes/
contact-sales/
+page.server.ts
+page.svelte
thanks/+page.svelte
vite.config.ts
.envimport { 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 |
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Der erste Kontakt im handle-Hook
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
patheines Cookies standardmäßig auf/. Ihn explizit zu setzen, lässt das Beispiel auch auf SvelteKit 2 funktionieren, wopathPflicht 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
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
picklistlehnt ein leeresselectmit deiner Meldung ab, undflattenliefert 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 vonfailals HTTP-Status.- Der
Origin-Header des Browsers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform daran kostenloselocalhost-Tests erkennt. redirect()wirft eine Exception, also läuft danach nichts mehr. Seit SvelteKit 2 rufst du es ohnethrowauf, 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 = falsemacht das explizit, falls ein übergeordnetes Layout Prerendering einschaltet.
Die Seite
<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
| 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.
- Starte
vite dev, öffne/?utm_source=test&utm_medium=emailund prüf das Cookielead_sourcein den Entwicklertools. - Wähle auf
/contact-saleseine Unternehmensgröße, lass Budget leer und sende ab. Der Budget-Fehler erscheint, und deine anderen Antworten bleiben. - 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.
- Sende mit Later this year. Du landest auf
/contact-sales/thanks. - Öffne Submissions im Dashboard und prüf
utm_medium,landing_pageundfit. - 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
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.
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.
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.
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.