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

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

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](https://ikiform.com/de/guides/lead-capture.md#form) 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.

Diagramm: Wo Astro die einzelnen Teile eines Lead-Formulars ausführt

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](https://ikiform.com/de/docs/guides/astro.md) behandelt das Anlegen von Formular und Key.

`Dateien`:

```text
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`:

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

## Erster Kontakt auf einer statischen Site

`src/components/FirstTouch.astro`:

```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`:

```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`:

```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](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md).

## 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](https://ikiform.com/de/pricing).

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

| 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

### Kann ich UTM-Parameter auf einer statischen Astro-Site erfassen?

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.

### Funktioniert eine Astro Form Action ohne JavaScript?

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

### Wie leite ich nach einer erfolgreichen Astro Action weiter?

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.

### 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 Next.js](https://ikiform.com/de/guides/lead-capture/nextjs.md): Ein Lead-Formular für den Next.js App Router, das in einer Server Action mit Zod validiert, UTMs des ersten Kontakts in einem Cookie hält und qualifizierte Leads in deinen Kalender schickt.
- [Lead-Formular mit SvelteKit](https://ikiform.com/de/guides/lead-capture/sveltekit.md): 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.
- [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.
