# Eine Warteliste in Astro bauen

> Eine Astro-Warteliste auf einer statischen Landingpage: Eine Action leitet jede Anmeldung weiter, ein Skript ruft sie auf, und eine On-Demand-Seite fängt Posts ohne JavaScript ab.

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

Lass die Landingpage vorgerendert und leg die Serverarbeit in eine Astro Action, die die E-Mail validiert und sie mit einem Secret Key an dein Formular-Backend weiterleitet. Ein kurzes Skript ruft die Action auf, damit der Besucher auf der Seite bleibt, und die einfache `action` des Formulars zeigt auf eine On-Demand-Seite, die dieselbe Action ausführt, wenn JavaScript nicht geladen ist.

Die [Hauptanleitung](https://ikiform.com/de/guides/waitlist.md#form) hat einen kurzen Astro-Tab: eine Action, ein Formular und eine Seite, die bei Bedarf rendert. Das funktioniert, gibt aber genau das auf, wofür die meisten Astro wählen. Eine Landingpage ist die Seite, die du am liebsten als statisches HTML von einem CDN ausliefern willst, und eine Astro-Form-Action zwingt sie, bei jedem Request zu rendern. Diese Seite hält die Landingpage statisch und verlegt nur die Anmeldung auf den Server.

## Was in Astro anders ist

Astro rendert standardmäßig jede Seite vor. Das hat zwei Folgen für eine Warteliste, die die anderen Frameworks nicht haben:

|   | Vorgerenderte Landingpage | Bei Bedarf gerenderte Seite |
| --- | --- | --- |
| Ausgeliefert von | Einem CDN, als Datei | Dem Server oder der Funktion deines Adapters |
| `<form action={actions.x}>` | Nicht erlaubt. Astro braucht die Seite on demand | Funktioniert ohne JavaScript |
| `?ref=` in der URL | Für den Server nicht sichtbar. Einmal gebaut, ohne Query | Lesbar über `Astro.url` |
| `Astro.clientAddress` | Nicht verfügbar | Verfügbar |

Die Landingpage kann also das Form-Action-Muster nicht nutzen und `?ref=` beim Build nicht lesen. Die Lösung ist, die Aufgaben aufzuteilen: Die statische Seite trägt das Formular und ein Skript, die Action macht die Serverarbeit, und eine kleine Seite, `/join`, rendert on demand, um einen Post ohne JavaScript abzufangen.

Diagramm: Zwei Wege in eine Astro Action von einer statischen Landingpage

1. Die Landingpage ist einfaches HTML vom CDN. Wenn ihr Skript lädt, kopiert es ?ref= oder ?utm_source= in das versteckte ref-Eingabefeld.
2. Mit JavaScript stoppt das Skript den normalen Post und ruft actions.joinWaitlist mit den Formulardaten auf. Die Action läuft auf dem Server und leitet die Anmeldung an Ikiform weiter.
3. Das Skript ersetzt das Formular durch eine Dankeszeile oder zeigt den Fehler unter dem Eingabefeld.
4. Ohne JavaScript sendet der Browser das Formular an /join, das on demand rendert.
5. /join ruft dieselbe Action mit Astro.callAction auf. Bei Erfolg leitet es mit einem 303 zu /thanks weiter, damit ein Neuladen nicht erneut sendet. Bei einem Fehler zeigt es das Formular mit der Meldung.

Eine Action, zwei Wege hinein. Nur /join und die Action selbst brauchen einen Server.

## Das Projekt einrichten

Die Produkteinrichtung, etwa das Anlegen des Formulars und des Secret Keys, steht in der [Astro-Anleitung in der Doku](https://ikiform.com/de/docs/guides/astro.md). Die Dateien für diese Seite:

`Projekt`:

```text
src/
  actions/index.ts
  components/WaitlistForm.astro
  pages/index.astro
  pages/join.astro
  pages/thanks.astro
astro.config.mjs
.env
```

### Ein Adapter und ein typisiertes Secret

Actions laufen zur Request-Zeit auf dem Server, das Projekt braucht also einen Adapter, auch wenn die meisten Seiten statisch bleiben. `astro add` installiert einen und bearbeitet die Konfiguration. Tausch `node` gegen `vercel`, `netlify` oder `cloudflare`, passend zu deinem Host.

`Terminal`:

```bash
npx astro add node
```

`astro.config.mjs`:

```js
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`envField` mit `access: "secret"` macht `IKIFORM_SECRET_KEY` nur aus `astro:env/server` importierbar. Importierst du ihn in einem Client-Skript, scheitert der Build, und genau das ist gewollt. Erstelle den Schlüssel unter **API keys** mit dem Scope `submissions:write`.

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

### Die Felddefinition

Die Action sendet `email` und `ref`. Wenn du im Tab **Fields** des Formulars eine Felddefinition hinzufügst, muss sie beide auflisten, sonst lehnt Ikiform den Post mit `422` und `unknown_field` ab:

`Felder`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## Bauen

### 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 = /^[a-z0-9_-]{1,40}$/;

export const server = {
  joinWaitlist: defineAction({
    accept: "form",
    input: z.object({
      email: z.email("Enter the email we should send your invite to."),
      ref: z
        .string()
        .nullish()
        .transform((value) => (value && SOURCE.test(value) ? value : "direct")),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      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({
          email: input.email,
          ref: input.ref,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      if (!response) {
        throw new ActionError({ code: "GATEWAY_TIMEOUT", message: "We couldn't reach the list. Try again." });
      }
      if (response.status === 429) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Lots of people are joining. Try again in a minute." });
      }
      const result = await response.json().catch(() => null);
      if (result?.ok) return { joined: true };
      throw new ActionError({
        code: "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"` parst `FormData`. Leere Eingabefelder kommen als `null` an, `ref` und der Honeypot sind also `nullish()`, und ein fehlendes `ref` wird zu `direct`.
- `ref` muss aus Kleinbuchstaben, Ziffern, `-` oder `_` bestehen, bis zu 40 Zeichen. Alles andere wird als `direct` gespeichert, ein präparierter Link kann deinen Export also nicht mit Müll füllen. Das Limit entspricht der `max_length` in der Definition.
- `z.email()` ist das E-Mail-Schema auf oberster Ebene von Zod 4, exportiert aus `astro/zod`. Eine falsche Adresse scheitert hier und erreicht Ikiform nie.
- Der Browser sendet sowohl beim Aufruf des Skripts als auch beim einfachen Post einen `Origin`-Header. Ikiform zählt kostenlose Testeinsendungen an diesem Header, und das eigene `fetch` der Action sendet keinen, die Action reicht ihn also weiter.
- Der Honeypot geht unverändert durch, Ikiform markiert Bots, die ihn ausfüllen, also weiterhin als Spam. Posts mit dem Secret Key überspringen Ikiforms Menschlichkeitsprüfung, **Spam check** zu aktivieren bringt hier also nichts; die Action ist das Tor.
- Jeder Fehler wirft einen `ActionError` mit einem Code, der zum Problem passt. Das Skript und `/join` lesen beide `error.message`.

### Die Formular-Komponente

`src/components/WaitlistForm.astro`:

```astro
---
interface Props {
  error?: string;
}

const { error = "" } = Astro.props;
---

<form method="POST" action="/join" data-waitlist>
  <label>
    Email
    <input name="email" type="email" autocomplete="email" required placeholder="you@company.com" />
  </label>
  <input type="hidden" name="ref" value="direct" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p role="alert">{error}</p>
  <button>Join the waitlist</button>
</form>

<script>
  import { actions, isInputError } from "astro:actions";

  const query = new URLSearchParams(location.search);
  const source = (query.get("ref") ?? query.get("utm_source") ?? "").toLowerCase().slice(0, 40);

  for (const form of document.querySelectorAll<HTMLFormElement>("form[data-waitlist]")) {
    const ref = form.elements.namedItem("ref") as HTMLInputElement;
    const alert = form.querySelector<HTMLElement>("[role=alert]")!;
    const button = form.querySelector("button")!;
    if (source) ref.value = source;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      button.textContent = "Joining";
      alert.textContent = "";

      const { error } = await actions.joinWaitlist(new FormData(form));
      if (!error) {
        const done = document.createElement("p");
        done.setAttribute("role", "status");
        done.textContent = "You're on the list. We'll email you on launch day.";
        form.replaceWith(done);
        return;
      }
      alert.textContent = isInputError(error) ? (error.fields.email?.[0] ?? "Check the address.") : error.message;
      button.disabled = false;
      button.textContent = "Join the waitlist";
    });
  }
</script>
```

- Die `action` des Formulars ist `/join`, eine normale URL. Ohne JavaScript sendet der Browser dorthin, das Formular funktioniert also, bevor das Skript lädt oder wenn es scheitert.
- Das Skript liest `?ref=` im Browser, weil eine vorgerenderte Seite ohne Query-String gebaut wurde. Ein Besucher von `/?ref=newsletter` wird mit `newsletter` markiert.
- Astro bündelt das `<script>` einer Komponente einmal pro Seite, die Schleife verdrahtet also jedes Wartelisten-Formular darauf. Setz eines in den Hero-Bereich und eines in den Footer, und beide funktionieren.
- Das Label umschließt das Eingabefeld, statt auf eine `id` zu verweisen, damit zwei Kopien auf einer Seite nicht kollidieren.

### Die Landingpage und /join

`src/pages/index.astro`:

```astro
---
import WaitlistForm from "../components/WaitlistForm.astro";
---

<main>
  <h1>Acme is almost here</h1>
  <p>Leave your email and we'll send you an invite the day it opens.</p>
  <WaitlistForm />
</main>
```

Kein `prerender`-Export, sie bleibt also statisch. `/join` steigt aus und verarbeitet den einfachen Post:

`src/pages/join.astro`:

```astro
---
import { actions, isInputError } from "astro:actions";
import WaitlistForm from "../components/WaitlistForm.astro";

export const prerender = false;

let error = "";
if (Astro.request.method === "POST") {
  const result = await Astro.callAction(actions.joinWaitlist, await Astro.request.formData());
  if (!result.error) return Astro.redirect("/thanks", 303);
  error = isInputError(result.error) ? (result.error.fields.email?.[0] ?? "Check the address.") : result.error.message;
  Astro.response.status = 400;
}
---

<main>
  <h1>Join the Acme waitlist</h1>
  <WaitlistForm error={error} />
</main>
```

`Astro.callAction` führt die Action mit derselben Validierung aus, es gibt also eine Stelle zum Ändern. Der `303` schickt den Browser mit einem GET zu `/thanks`, einer statischen Seite, die du mit derselben Dankeszeile schreibst.

## Hinweise zum Deployment

| Host | Adapter | Das Secret |
| --- | --- | --- |
| Vercel | `npx astro add vercel` | Füge `IKIFORM_SECRET_KEY` unter **Environment Variables** des Projekts hinzu |
| Netlify | `npx astro add netlify` | Füge ihn unter **Site configuration › Environment variables** hinzu |
| Cloudflare | `npx astro add cloudflare` | Füge ihn als Secret in den Einstellungen des Workers hinzu oder mit `wrangler secret put IKIFORM_SECRET_KEY` |

Auf jedem Host gehen die Landingpage und `/thanks` als statische Dateien raus, und die Action plus `/join` werden zu einer Serverfunktion. Ein ruhiger Tag kostet fast nichts, und ein Launch-Ansturm trifft hauptsächlich das CDN.

## Prüfen, ob es funktioniert

1. Führe `astro dev` aus und öffne `http://localhost:4321/?ref=test`. Melde dich an. Das Formular wird ohne Neuladen zur Dankeszeile.
2. Öffne im Dashboard **Submissions**. Die Anmeldung ist da, mit `ref` auf `test`.
3. Schalte im Browser JavaScript aus, lade neu und melde dich erneut an. Du landest auf `/thanks`, und die neue Anmeldung hat `ref` auf `direct`.
4. Sende mit ausgeschaltetem JavaScript `not-an-email` ab. `/join` zeigt das Formular erneut mit dem E-Mail-Fehler.
5. Sende einen Post mit ausgefülltem Honeypot, `_gotcha=bot`. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus.
6. Posts aus `astro dev` tragen einen `localhost`-Origin, zählen also als Testeinsendungen. 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).

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| `403` von deiner eigenen Site beim einfachen Post | Astros `security.checkOrigin`, standardmäßig aktiv, lehnt Formular-Posts an On-Demand-Seiten von einem anderen Origin ab. Sende vom selben Host, auf dem die Site läuft, etwa `localhost:4321` in der Entwicklung, nicht über eine andere Preview-Domain. |
| Ein Build-Fehler zu Form-Actions auf einer vorgerenderten Seite | Ein Formular nutzt `action={actions.joinWaitlist}` auf einer statischen Seite. Lass die action bei `/join`, wie oben, oder füge dieser Seite `export const prerender = false` hinzu. |
| `ref` ist immer `direct` | Der Link nutzt einen Wert mit Großbuchstaben oder Leerzeichen, oder das Skript wurde nicht geladen. Das Skript schreibt den Wert klein; alles, was danach außerhalb von `a-z`, `0-9`, `-` und `_` liegt, verwirft die Action. |
| `422` mit `unknown_field` | Die Felddefinition des Formulars listet `ref` nicht auf. Füge es wie in der Definition oben hinzu. |
| `402 plan_required` | Der Workspace hat keinen Tarif, und der Post kam von einer Live-Site. Er wird zurückgehalten, nicht verloren. Wähle einen Tarif oder kauf ein Paket unter **Settings › Plan and usage**, und zurückgehaltene Einsendungen kommen an. |

## Fragen

### Kann eine Astro-Wartelisten-Seite statisch bleiben?

Ja. Lass die Landingpage vorgerendert, ruf die Action aus einem Client-Skript auf und richte die einfache action des Formulars auf eine Seite aus, die on demand rendert. Nur die Action und diese Seite brauchen einen Server.

### Brauchen Astro Actions einen Adapter?

Ja. Actions laufen auf dem Server, wenn sie aufgerufen werden, das Projekt braucht also einen Adapter wie Node, Vercel, Netlify oder Cloudflare, auch wenn jede Seite vorgerendert ist.

### Warum das Astro-Formular nicht direkt an Ikiform senden?

Das geht, und es behält Ikiforms Rate-Limit pro Besucher und die Menschlichkeitsprüfung. Die Action lohnt sich, wenn du den Quellen-Tag zuerst validieren und bereinigen oder den Endpunkt aus dem HTML deiner Seite heraushalten willst.

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

- [Warteliste für ein neues SaaS](https://ikiform.com/de/guides/waitlist.md): Baue eine Pre-Launch-Warteliste auf deiner eigenen Landingpage: das Formular, Spamschutz, Duplikate, Bestätigungs-E-Mails und ein sauberer Export am Launch-Tag.
- [Warteliste mit Next.js](https://ikiform.com/de/guides/waitlist/nextjs.md): Eine Warteliste mit dem Next.js App Router: eine Server Action, die Anmeldungen mit Secret Key weiterleitet, useActionState, ein optimistisches Danke und ein Limit pro Besucher.
- [Warteliste in reinem HTML](https://ikiform.com/de/guides/waitlist/html.md): Eine komplette Wartelisten-Seite in reinem HTML ohne Build, die ohne JavaScript funktioniert, nach dem Laden ein Danke inline zeigt und bei Bedarf zurückfällt.
- [Launch-Liste für Product Hunt](https://ikiform.com/de/guides/waitlist/product-hunt.md): Sammle Wochen vor Product Hunt eine Launch-Liste, markiere jede Anmeldung nach Kanal und sende die Launch-E-Mail in zwei Wellen, abgestimmt auf den Reset nach Pacific Time.
- [Beta-Anmeldung mit Auswahl](https://ikiform.com/de/guides/waitlist/beta-signup.md): Ein Beta-Bewerbungsformular mit Auswahlfragen, die der Server durchsetzt, und ein Skript, das Bewerber bewertet und jede Kohorte aus der Liste auswählt.
- [Warteliste für mobile Apps](https://ikiform.com/de/guides/waitlist/mobile-app.md): Eine Pre-Launch-Warteliste für eine iOS- oder Android-App, die fragt, welches Handy jemand nutzt, iPhone-Tester zu TestFlight schickt und einen Play-Closed-Test füllt.
