Formular zur Lead-Erfassung7 Min. Lesezeit
Qualifizierendes Lead-Formular mit Astro bauen
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.
Lass deine Marketingseiten vorgerendert und halte UTM-Tags und Referrer des ersten Besuchs mit einem Client-Skript in einem kleinen Cookie fest, denn Middleware läuft bei einer vorgerenderten Seite nur zur Build-Zeit. Rendere die Kontaktseite on demand, validiere den Lead in einer Astro Action mit astro/zod und leite ihn mit der Attribution an dein Formular-Backend weiter. Gib aus der Action die nächste URL zurück und ruf Astro.redirect() auf, um qualifizierte Leads zu deinem Terminplaner zu schicken.
Der Astro-Tab der Hauptanleitung sendet einen Lead über eine Action und zeigt eine Dankeszeile. Diese Seite macht daraus ein Vertriebsformular: Unternehmensgröße, Budget und Zeitplan, von Zod auf deinem Server geprüft, Kampagnendaten, die eine statische Site überleben, und eine Übergabe an deinen Kalender für passende Leads.
Was Astro ändert
Astro rendert Seiten standardmäßig vor, und das prägt alles an der Attribution. Middleware läuft für eine vorgerenderte Seite einmal, zur Build-Zeit, ohne Besucher, ohne Cookies und ohne Query-String. Die Landingpage, auf der ?utm_source= tatsächlich auftaucht, ist fast immer vorgerendert, also sieht Middleware die Tags nie.
Also wird der erste Kontakt stattdessen im Browser festgehalten, von einem kurzen Skript in deinem Layout, und auf dem Server von der Action wieder gelesen. Die Kontaktseite ist die einzige Seite, die on demand rendern muss, denn die Astro-Doku verlangt das für jede Seite, die eine Action aus einem Formular aufruft.
- Zur Build-Zeit läuft Middleware für vorgerenderte Seiten ohne Request-Daten, kann also keinen Besuch festhalten.
- Im Browser liest das FirstTouch-Skript beim ersten Seitenaufruf die UTM-Tags und den Referrer.
- Es speichert sie einmalig in einem lead_source-Cookie, das jeder spätere Request mitträgt.
- Das Kontaktformular sendet mit einem einfachen HTML-Formular an die Action, ohne JavaScript.
- Die Action validiert mit Zod, liest das Cookie und leitet den Lead an Ikiform weiter.
- Die Seite liest das Ergebnis und leitet zum Terminplaner oder zur Dankeseite weiter; Eingabefehler erscheinen unter ihren Feldern.
Projekt-Setup
Füge mit npx astro add vercel (oder netlify oder cloudflare) einen Adapter hinzu und deklariere dann den Key mit astro:env, damit Astro ohne ihn nicht baut und ihn nie an den Browser ausliefert. Die Astro-Seite der Doku behandelt das Anlegen von Formular und Key.
src/
actions/index.ts
components/FirstTouch.astro
layouts/Base.astro
pages/
pricing.astro
contact-sales.astro
contact-sales/thanks.astro
astro.config.mjs
.envimport { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";
export default defineConfig({
adapter: vercel(),
env: {
schema: {
IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
},
},
});Ohne gesetztes output bleibt jede Seite vorgerendert, und nur Seiten, die prerender = false exportieren, laufen auf dem Server. Füge die Felder unten im Tab Fields des Formulars hinzu, damit Ikiform dieselben Regeln prüft und alles andere ablehnt.
| 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.Erster Kontakt auf einer statischen Site
<script>
const TAGS = ["utm_source", "utm_medium", "utm_campaign"];
if (!document.cookie.split("; ").some((pair) => pair.startsWith("lead_source="))) {
const params = new URLSearchParams(location.search);
const from = URL.canParse(document.referrer) ? new URL(document.referrer) : null;
const external = from && from.host !== location.host ? from.href.slice(0, 300) : "";
const source = new URLSearchParams();
for (const tag of TAGS) source.set(tag, (params.get(tag) ?? "").slice(0, 100));
if (external || TAGS.some((tag) => source.get(tag))) {
source.set("referrer", external);
source.set("landing_page", location.pathname.slice(0, 200));
document.cookie = `lead_source=${encodeURIComponent(source.toString())}; path=/; max-age=2592000; samesite=lax`;
}
}
</script>- Setze
<FirstTouch />in das Layout, das jede Seite nutzt. Astro bündelt das Skript einmal und führt es bei jedem Seitenladen aus; die Cookie-Prüfung sorgt dafür, dass nur der erste Besuch zählt. - Das Cookie ist nicht
httpOnly, weil ein Skript es setzt. Behandle es als Hinweis: Die Action liest nur fünf bekannte Keys, und Ikiform begrenzt die Länge jedes Felds. - Ein Referrer von deinem eigenen Host wird ignoriert, sodass ein Klick von Pricing zu Contact die echte Quelle nicht überschreibt.
- Rendern deine Landingpages stattdessen on demand, passt dieselbe Logik mit
context.cookies.set()insrc/middleware.ts. Prüf zuerstcontext.isPrerenderedund überspringe dann.
Die Action
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
const SOURCE = ["utm_source", "utm_medium", "utm_campaign", "referrer", "landing_page"];
export const server = {
submitLead: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us who to reply to.").max(120),
email: z.email("Enter a work email we can reply to."),
company: z.string().trim().min(1, "Which company is this for?").max(200),
company_size: z.enum(["1_10", "11_50", "51_200", "201_1000", "1000_plus"], { error: "Pick a company size." }),
budget: z.enum(["under_10k", "10k_50k", "50k_plus", "unsure"], { error: "Pick a budget range." }),
timeline: z.enum(["this_quarter", "next_quarter", "later", "researching"], { error: "Pick a timeline." }),
message: z.string().trim().max(5000).nullish(),
_gotcha: z.string().nullish(),
}),
handler: async (lead, context) => {
const stored = new URLSearchParams(context.cookies.get("lead_source")?.value ?? "");
const source = Object.fromEntries(SOURCE.map((key) => [key, stored.get(key) ?? ""]));
const qualified =
lead.company_size !== "1_10" &&
["10k_50k", "50k_plus"].includes(lead.budget) &&
["this_quarter", "next_quarter"].includes(lead.timeline);
const origin = context.request.headers.get("origin");
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 ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...lead, ...source, fit: qualified ? "qualified" : "nurture" }),
});
const result = await response.json().catch(() => null);
if (!result?.ok) {
throw new ActionError({
code: response.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
message: result?.error?.message ?? "We couldn't send that. Email sales@example.com instead.",
});
}
if (!qualified || lead._gotcha) return { next: "/contact-sales/thanks" };
return { next: `https://cal.com/acme/intro?${new URLSearchParams({ name: lead.name, email: lead.email })}` };
},
}),
};- Mit
accept: "form"macht Astro aus einem leeren Inputnull, bevor Zod ihn sieht. Ein nicht gewähltesselectscheitert deshalb anz.enummit deiner eigenen Meldung, und optionaler Text brauchtnullish(). - Der
Origin-Header des Browsers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform daran kostenloselocalhost-Tests erkennt. Inastro devist erhttp://localhost:4321. - Die Action gibt zurück, wohin es als Nächstes geht, statt selbst umzuleiten. Actions können
redirectnicht aufrufen; die Seite erledigt das mit dem Ergebnis. - Ikiforms
429wird zuTOO_MANY_REQUESTS, damit die Seite ein ausgelastetes Formular von einer fehlerhaften Eingabe unterscheiden kann.
Die Kontaktseite
---
import { actions, isInputError } from "astro:actions";
import Base from "../layouts/Base.astro";
export const prerender = false;
const result = Astro.getActionResult(actions.submitLead);
if (result && !result.error) return Astro.redirect(result.data.next, 303);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
const failure = result?.error && !isInputError(result.error) ? result.error.message : "";
---
<Base title="Talk to sales">
<form method="POST" action={actions.submitLead}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Work email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="company">Company</label>
<input id="company" name="company" autocomplete="organization" required />
{fields.company && <p role="alert">{fields.company[0]}</p>}
<label for="company_size">Company size</label>
<select id="company_size" name="company_size" required>
<option value="">Choose one</option>
<option value="1_10">1 to 10</option>
<option value="11_50">11 to 50</option>
<option value="51_200">51 to 200</option>
<option value="201_1000">201 to 1,000</option>
<option value="1000_plus">More than 1,000</option>
</select>
{fields.company_size && <p role="alert">{fields.company_size[0]}</p>}
<label for="budget">Budget</label>
<select id="budget" name="budget" required>
<option value="">Choose one</option>
<option value="under_10k">Under $10k</option>
<option value="10k_50k">$10k to $50k</option>
<option value="50k_plus">More than $50k</option>
<option value="unsure">Not sure yet</option>
</select>
{fields.budget && <p role="alert">{fields.budget[0]}</p>}
<label for="timeline">When do you want to start?</label>
<select id="timeline" name="timeline" required>
<option value="">Choose one</option>
<option value="this_quarter">This quarter</option>
<option value="next_quarter">Next quarter</option>
<option value="later">Later this year</option>
<option value="researching">Researching for now</option>
</select>
{fields.timeline && <p role="alert">{fields.timeline[0]}</p>}
<label for="message">Anything we should know?</label>
<textarea id="message" name="message" rows="4"></textarea>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{failure && <p role="alert">{failure}</p>}
<button>Talk to sales</button>
</form>
</Base>
<script>
const form = document.querySelector("form");
form?.addEventListener("submit", () => {
const button = form.querySelector("button");
if (button) {
button.disabled = true;
button.textContent = "Sending";
}
});
</script>Das Formular sendet auf dem einfachen HTML-Weg, funktioniert also auch mit ausgeschaltetem JavaScript. Astro führt die Action aus, rendert dieselbe Seite mit dem Ergebnis, und das Frontmatter leitet entweder mit einem 303 weiter oder rendert die Fehler. isInputError trennt die feldbezogenen Meldungen von Zod von den eigenen Fehlern der Action. Das kleine Skript deaktiviert nur den Button, damit ein Doppelklick den Lead nicht zweimal sendet.
Honeypot und Menschlichkeitsprüfung
Der Honeypot ist Teil des Schemas und wird weitergeleitet, sodass Ikiform einen ausgefüllten als Spam speichert, und die Action schickt ihn auf die Dankeseite statt in deinen Kalender. Der Secret Key überspringt die eigene Menschlichkeitsprüfung von Ikiform. Finden Bots die Seite, verifiziere im Handler ein Turnstile-Token, wie in Turnstile in einem Formular.
Hinweise zum Deployment
| Host | Gut zu wissen |
|---|---|
| Vercel | @astrojs/vercel führt die Kontaktseite und Actions als Functions aus und liefert den Rest als statische Dateien. Füge IKIFORM_SECRET_KEY in den Umgebungsvariablen des Projekts hinzu. |
| Netlify | @astrojs/netlify macht dasselbe mit Netlify Functions. Setze den Key in den Umgebungsvariablen der Site. |
| Cloudflare | @astrojs/cloudflare führt On-Demand-Seiten in einem Worker aus. Speichere den Key mit wrangler secret put als Secret; astro:env liest ihn zur Laufzeit. |
Prüfen, ob es funktioniert
Posts aus astro 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
astro dev, öffne/pricing?utm_source=test&utm_campaign=launchund prüf das Cookielead_sourcein den Entwicklertools. - Geh zu
/contact-sales, lass Budget leer und sende mit ausgeschaltetem JavaScript ab. Die Seite lädt mit dem Budget-Fehler neu. - Sende als qualifizierter Lead: 11 bis 50 Personen, mehr als $50k, nächstes Quartal. Du landest auf Cal.com mit ausgefülltem Namen und E-Mail.
- Sende mit Researching for now. Du landest auf
/contact-sales/thanks. - Öffne Submissions im Dashboard und prüf
utm_source,referrer,landing_pageundfitbei beiden Leads. - 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 |
|---|---|
| Das Formular sendet, und die Seite zeigt nichts Neues | Die Kontaktseite ist vorgerendert. Füge ihr export const prerender = false hinzu; die Astro-Doku verlangt On-Demand-Rendering für Form Actions. |
| Nach einem Fehler ist jedes Feld leer | Astro leert Inputs beim Absenden eines Formulars. Um sie zu behalten, aktiviere View Transitions mit <ClientRouter /> und füge jedem Input transition:persist hinzu. |
| UTM-Felder sind leer, obwohl der Link Tags hatte | Das Cookie existierte schon von einem früheren Besuch, oder das Layout mit <FirstTouch /> wird auf der Landingpage nicht genutzt. Lösche das Cookie und versuch es erneut. |
IKIFORM_SECRET_KEY is missing beim Build | astro:env validiert das Schema. Setze die Variable lokal in .env und in den Einstellungen deines Hosts. |
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, aber nicht in Middleware, die bei vorgerenderten Seiten zur Build-Zeit läuft. Ein kleines Skript in deinem Layout liest beim ersten Besuch Tags und Referrer und speichert sie in einem Cookie, und die Action liest das Cookie, wenn der Lead absendet.
Ja. Ein Formular mit method POST und der Action als action sendet auf dem einfachen HTML-Weg. Die Seite muss on demand rendern und liest das Ergebnis mit Astro.getActionResult().
Gib die URL aus dem Handler zurück, prüfe dann im Frontmatter der Seite Astro.getActionResult() und gib Astro.redirect() mit dieser URL zurück, wenn es keinen Fehler gibt. Eine absolute URL wie dein Cal.com-Link funktioniert.
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.