Formular zur Lead-Erfassung

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.

Preet SutharGründer, Ikiform

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.

Wo Astro die einzelnen Teile eines Lead-Formulars ausführtAttributionabsendenMiddlewareläuft einmal,ohne BesucherSkriptFirstTouchliest ?utm_ undReferrerCookielead_sourcemit dem PostgesendetFormular-POSTaction={actions.submitLead}Astro ActionZod, liestCookieAstro.redirectCal.com oder/thanksgleicheSeiteMeldung unterjedem FeldsetztFormulardatenresult.data.nextisInputErrorBuild-Zeitim Browserbei BedarfWo Astro die einzelnen Teile eines Lead-Formulars ausführtAttributionabsendenMiddlewareläuft einmal, ohneBesucherSkriptFirstTouchliest ?utm_ undReferrerCookielead_sourcemit dem PostgesendetFormular-POSTaction={actions.submitLead}Astro ActionZod, liest CookieAstro.redirectCal.com oder /thanksgleicheSeiteMeldungunter jedemFeldsetztFormulardatenresult.data.nextisInputError
  1. Zur Build-Zeit läuft Middleware für vorgerenderte Seiten ohne Request-Daten, kann also keinen Besuch festhalten.
  2. Im Browser liest das FirstTouch-Skript beim ersten Seitenaufruf die UTM-Tags und den Referrer.
  3. Es speichert sie einmalig in einem lead_source-Cookie, das jeder spätere Request mitträgt.
  4. Das Kontaktformular sendet mit einem einfachen HTML-Formular an die Action, ohne JavaScript.
  5. Die Action validiert mit Zod, liest das Cookie und leitet den Lead an Ikiform weiter.
  6. Die Seite liest das Ergebnis und leitet zum Terminplaner oder zur Dankeseite weiter; Eingabefehler erscheinen unter ihren Feldern.
Nur die Kontaktseite braucht einen Server. Alles, was ein Besucher davor liest, bleibt statisch.

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.

Dateien
src/
  actions/index.ts
  components/FirstTouch.astro
  layouts/Base.astro
  pages/
    pricing.astro
    contact-sales.astro
    contact-sales/thanks.astro
astro.config.mjs
.env
astro.config.mjs
import { 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-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.

Erster Kontakt auf einer statischen Site

src/components/FirstTouch.astro
<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() in src/middleware.ts. Prüf zuerst context.isPrerendered und überspringe dann.

Die Action

src/actions/index.ts
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 Input null, bevor Zod ihn sieht. Ein nicht gewähltes select scheitert deshalb an z.enum mit deiner eigenen Meldung, und optionaler Text braucht nullish().
  • Der Origin-Header des Browsers wird weitergereicht, weil ein serverseitiges fetch keinen sendet und Ikiform daran kostenlose localhost-Tests erkennt. In astro dev ist er http://localhost:4321.
  • Die Action gibt zurück, wohin es als Nächstes geht, statt selbst umzuleiten. Actions können redirect nicht aufrufen; die Seite erledigt das mit dem Ergebnis.
  • Ikiforms 429 wird zu TOO_MANY_REQUESTS, damit die Seite ein ausgelastetes Formular von einer fehlerhaften Eingabe unterscheiden kann.

Die Kontaktseite

src/pages/contact-sales.astro
---
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

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

  1. Starte astro dev, öffne /pricing?utm_source=test&utm_campaign=launch und prüf das Cookie lead_source in den Entwicklertools.
  2. Geh zu /contact-sales, lass Budget leer und sende mit ausgeschaltetem JavaScript ab. Die Seite lädt mit dem Budget-Fehler neu.
  3. 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.
  4. Sende mit Researching for now. Du landest auf /contact-sales/thanks.
  5. Öffne Submissions im Dashboard und prüf utm_source, referrer, landing_page und fit bei beiden Leads.
  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
Das Formular sendet, und die Seite zeigt nichts NeuesDie 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 leerAstro 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 hatteDas 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 Buildastro:env validiert das Schema. Setze die Variable lokal in .env und in den Einstellungen deines Hosts.
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.