# Newsletter-Anmeldeformular mit Astro bauen

> Ein Newsletter-Formular mit Astro, das auf vorgerenderten Seiten bleibt: Eine Astro Action prüft die Einwilligung und sendet an Ikiform, und ein Endpunkt fügt Leser zu MailerLite hinzu.

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

Lass deine Astro-Seiten vorgerendert und setze das Anmeldeformular in die Footer-Komponente. Ohne JavaScript sendet es an eine On-Demand-Seite `/newsletter`, die eine Astro Action ausführt; mit JavaScript ruft ein kleines Skript dieselbe Action auf, und der Leser verlässt den Artikel nie. Die Action validiert E-Mail und Einwilligung mit `astro/zod` und sendet mit einem Secret Key an dein Formular-Backend, und ein API-Endpunkt leitet verifizierte Anmeldungen an MailerLite weiter.

Der [Astro-Tab der Hauptanleitung](https://ikiform.com/de/guides/newsletter-signup.md#form) nutzt eine Action auf einer Seite, die on demand gerendert wird. Die meisten Astro-Sites sind Blogs und Dokus, auf denen jede Seite vorgerendert ist, und ein Footer-Formular muss auf allen leben. Diese Seite lässt diese Seiten statisch und führt die Anmeldung trotzdem auf dem Server aus. Keys und erlaubte Domains stehen in der [Astro-Anleitung der Doku](https://ikiform.com/de/docs/guides/astro.md).

## Was statische Seiten ändern

Laut Astro-Doku muss eine Seite on demand gerendert werden, um eine Action über das `action`-Attribut eines Formulars aufzurufen. Ein vorgerenderter Artikel kann diesen POST nicht verarbeiten, weil kein Server dahintersteht. Es gibt zwei Wege darum herum, und dieser Aufbau nutzt beide:

| Weg | Wann er läuft | Was der Leser sieht |
| --- | --- | --- |
| Skript ruft `actions.subscribe()` auf | JavaScript geladen, der übliche Fall | Die Statuszeile unter dem Formular ändert sich. Er bleibt auf dem Artikel |
| Formular sendet an `/newsletter?_action=...` | JavaScript aus oder noch nicht geladen | Die On-Demand-Seite `/newsletter`, mit dem Ergebnis oder dem Fehler |

Nur `/newsletter` und der Webhook-Endpunkt laufen on demand. Jeder Artikel bleibt eine statische Datei, und der Adapter fügt für diese zwei Routen eine Server-Funktion hinzu.

Diagramm: Zwei Wege, auf denen eine Footer-Anmeldung in Astro dieselbe Action erreicht

1. Mit JavaScript stoppt das Footer-Skript den normalen Post und ruft die Action mit den Daten des Formulars auf.
2. Die Action validiert die Eingabe, sendet an Ikiform und gibt Daten oder einen Fehler an das Skript zurück, das die Statuszeile aktualisiert.
3. Ohne JavaScript sendet der Browser das Formular an die On-Demand-Seite /newsletter, mit der Action im Query-String.
4. Astro führt denselben Action-Handler aus, und die Seite liest das Ergebnis mit getActionResult und zeigt es an.

Ein Handler, zwei Einstiegspunkte. Der statische Artikel braucht nie einen eigenen Server.

## Projekt-Setup

`Projektstruktur`:

```text
src/
  actions/index.ts
  components/SignupForm.astro
  layouts/Base.astro
  pages/
    newsletter.astro
    api/ikiform.ts
astro.config.mjs
.env
```

Installiere einen Adapter mit `astro add vercel`, `astro add netlify` oder `astro add cloudflare`. Deklariere die Secrets mit `astro:env`, statt `import.meta.env` zu lesen: Das Schema lässt den Build scheitern, wenn eine Variable fehlt, und derselbe Import funktioniert mit jedem Adapter, auch in der Runtime von Cloudflare.

`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" }),
      IKIFORM_WEBHOOK_SECRET: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_API_KEY: envField.string({ context: "server", access: "secret" }),
      MAILERLITE_GROUP_ID: envField.string({ context: "server", access: "secret" }),
    },
  },
});
```

`.env`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789
```

Erstelle unter **API keys** den Secret Key mit dem Scope `submissions:write` und füge die [Felddefinition der Hauptanleitung](https://ikiform.com/de/guides/newsletter-signup.md#fields) im Tab **Fields** des Formulars ein.

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

export const server = {
  subscribe: defineAction({
    accept: "form",
    input: z.object({
      email: z.email({ error: "Enter an email address we can send the newsletter to." }),
      first_name: z.string().trim().max(80).nullish(),
      consent: z.boolean().refine((ticked) => ticked, { error: "Tick the box so we know you want the emails." }),
      source: z.enum(["footer", "post", "newsletter-page"]).catch("footer"),
      _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,
          first_name: input.first_name || undefined,
          consent: true,
          source: input.source,
          _gotcha: input._gotcha ?? "",
        }),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { subscribed: true };
      throw new ActionError({
        code: response?.status === 429 ? "TOO_MANY_REQUESTS" : "BAD_REQUEST",
        message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "We couldn't reach the list. Try again.",
      });
    },
  }),
};
```

- `accept: "form"` lässt Astro die `FormData` parsen. Die Checkbox durchläuft eine Boolean-Umwandlung, also ist angehakt `true` und nicht angehakt `false`, was `refine` zur Einwilligungsmeldung macht.
- Leere Text-Inputs kommen als `null` an, also nutzen optionale Felder `nullish()`, nicht `optional()`.
- `source` fällt bei jedem Wert außerhalb der Liste auf `footer` zurück, sodass ein manipulierter Post keine Platzierungen erfinden kann.
- Die `Origin`-Zeile reicht den Origin des Browsers weiter, denn Ikiform erkennt kostenlose `localhost`-Tests an diesem Header, und das `fetch` eines Servers sendet keinen.
- Die Fehler von Ikiform werden zu einem `ActionError`. Ein `429` behält seine Bedeutung als `TOO_MANY_REQUESTS`.

> 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 Footer-Komponente

`src/components/SignupForm.astro`:

```astro
---
import { actions } from "astro:actions";

interface Props {
  source?: "footer" | "post" | "newsletter-page";
}

const { source = "footer" } = Astro.props;
const target = new URL("/newsletter", Astro.url);
target.search = actions.subscribe.queryString;
---

<form class="signup" method="POST" action={target.pathname + target.search}>
  <label>Email <input name="email" type="email" required autocomplete="email" /></label>
  <label>First name (optional) <input name="first_name" maxlength="80" autocomplete="given-name" /></label>
  <label>
    <input name="consent" type="checkbox" required />
    Email me the newsletter. I can unsubscribe at any time.
  </label>
  <input type="hidden" name="source" value={source} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <p class="signup-status" role="status"></p>
  <button>Subscribe</button>
</form>

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

  for (const form of document.querySelectorAll<HTMLFormElement>("form.signup")) {
    const status = form.querySelector(".signup-status") as HTMLElement;
    const button = form.querySelector("button") as HTMLButtonElement;

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      status.textContent = "Subscribing";
      const { error } = await actions.subscribe(new FormData(form));
      button.disabled = false;
      if (!error) {
        form.replaceChildren(status);
        status.textContent = "Almost done. Open the email we sent you and confirm your address.";
        return;
      }
      const fields = isInputError(error) ? error.fields : {};
      status.textContent = fields.email?.[0] ?? fields.consent?.[0] ?? error.message;
    });
  }
</script>
```

- `queryString` ist der dokumentierte Weg, eine Action-URL zu bauen. Auf einer `/newsletter`-URL gesetzt, schickt es Posts ohne JavaScript an die On-Demand-Seite statt an die statische, von der sie kamen.
- Astro bündelt das `<script>` einer Komponente einmal, egal wie oft die Komponente vorkommt. Die Schleife verdrahtet jede Kopie, etwa ein Footer-Formular und eins am Ende eines Beitrags.
- `isInputError` trennt die feldbezogenen Meldungen von Zod von den Ablehnungen durch Ikiform, die als einfache Meldung ankommen.

### Die On-Demand-Seite

`src/pages/newsletter.astro`:

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

export const prerender = false;

const result = Astro.getActionResult(actions.subscribe);
const fields = isInputError(result?.error) ? result.error.fields : {};
const problem = fields.email?.[0] ?? fields.consent?.[0] ?? result?.error?.message;
---

<Base title="Newsletter">
  <h1>The monthly newsletter</h1>
  {result?.data?.subscribed ? (
    <p role="status">Almost done. Open the email we sent you and confirm your address.</p>
  ) : (
    <>
      {problem && <p role="alert">{problem}</p>}
      <SignupForm source="newsletter-page" />
    </>
  )}
</Base>
```

Astro zeigt das Ergebnis einer Action nur für diesen POST. Ein erneuter Besuch der Seite löscht es, was für eine Anmeldung in Ordnung ist. Willst du lieber auf eine Danke-URL weiterleiten, zeigt die Astro-Doku das Session-basierte Muster mit `getActionContext` in der Middleware.

## Abonnenten an MailerLite weiterleiten

Dieser Endpunkt empfängt den signierten Webhook von Ikiform und fügt jede Anmeldung einer MailerLite-Gruppe als `unconfirmed` hinzu, sodass MailerLite seine eigene Bestätigung sendet, wenn **Double opt-in for API and integrations** aktiviert ist. Er liest den Body zuerst als Text, weil die Signatur genau die Bytes abdeckt. Er sendet jede Anmeldung als `unconfirmed`, was zu einer neuen Liste passt. Hat deine Liste schon bestätigte Leser, nutze stattdessen das Relay aus der [MailerLite-Anleitung](https://ikiform.com/de/guides/newsletter-signup/mailerlite.md): Es schlägt die Adresse zuerst nach, sodass ein bestätigter Leser, der sich erneut anmeldet, seinen Status behält.

`src/pages/api/ikiform.ts`:

```ts
import type { APIRoute } from "astro";
import { IKIFORM_WEBHOOK_SECRET, MAILERLITE_API_KEY, MAILERLITE_GROUP_ID } from "astro:env/server";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  let event;
  try {
    event = await verifyWebhook({ payload: await request.text(), headers: request.headers, secret: IKIFORM_WEBHOOK_SECRET });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
  if (event.type !== "submission.created") return new Response(null, { status: 204 });

  const { email, first_name, consent } = event.data.fields;
  if (consent !== true || typeof email !== "string") return new Response("Skipped", { status: 200 });

  const answer = await fetch("https://connect.mailerlite.com/api/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${MAILERLITE_API_KEY}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      email,
      fields: typeof first_name === "string" && first_name ? { name: first_name } : {},
      groups: [MAILERLITE_GROUP_ID],
      status: "unconfirmed",
    }),
  });

  if (answer.status === 429) return new Response("MailerLite rate limit", { status: 503, headers: { "retry-after": "60" } });
  if (!answer.ok) return new Response(`MailerLite answered ${answer.status}`, { status: 502 });
  return new Response(null, { status: 204 });
};
```

Astros `security.checkOrigin`, standardmäßig aktiv, lehnt Cross-Site-Formular-Posts an On-Demand-Routen ab. Der Webhook von Ikiform ist `application/json`, ein Typ, für den die Prüfung nicht gilt, also empfängt der Endpunkt ihn unverändert. Füge die URL unter **Webhooks** hinzu und kopiere das `whsec_`-Secret in `IKIFORM_WEBHOOK_SECRET`.

## Honeypot und Menschlichkeitsprüfung

Der `_gotcha`-Input wird weitergeleitet, und Ikiform speichert einen ausgefüllten als Spam, mit Erfolgsantwort und ohne Webhook, sodass er MailerLite nie erreicht. Der Secret Key überspringt die Turnstile-Prüfung von Ikiform. Kommen Bots durch, hast du zwei Möglichkeiten: Verifiziere im Handler ein Turnstile-Token, wie die [Turnstile-Anleitung](https://ikiform.com/de/guides/spam-protection/cloudflare-turnstile.md) zeigt, oder streich die Action und sende mit `@ikiform/sdk` aus dem Browser, was Ikiforms Prüfung, erlaubte Domains und Limits pro Besucher beibehält und gar keinen Adapter braucht.

## Deployen

| Host | Was zu tun ist |
| --- | --- |
| Vercel | `@astrojs/vercel` macht aus den zwei On-Demand-Routen Functions. Füge die vier Variablen unter **Environment Variables** hinzu. |
| Netlify | `@astrojs/netlify` macht dasselbe mit Netlify Functions. Füge die Variablen in den Umgebungseinstellungen der Site hinzu. |
| Cloudflare | `@astrojs/cloudflare` zielt auf Workers; Pages unterstützt der Adapter nicht mehr. Setze Secrets mit `wrangler secret put` und nutze lokal `.dev.vars`. Gib dem Worker eine eigene Domain, denn Ikiform lehnt `workers.dev`-URLs für Webhooks ab. |

## Kostenlos auf localhost testen

Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat von `localhost` an. Starte `astro dev`, und der weitergereichte `Origin` ist `http://localhost:4321`, also zählt jede Anmeldung als Test. Probier beide Wege aus: einmal normal und einmal mit deaktiviertem JavaScript, um die Seite `/newsletter` zu sehen. Um den Endpunkt zu testen, mach ihn über einen Tunnel mit HTTPS-URL erreichbar; Ikiform stellt nicht an `localhost` zu.

## Prüfen, ob es funktioniert

1. Führe `astro build` aus und prüfe, dass deine Artikel in der Ausgabe weiterhin als vorgerendert gelistet sind und nur `/newsletter` und `/api/ikiform` on demand laufen.
2. Abonniere über den Footer eines Artikels. Die Statuszeile ändert sich, die URL nicht.
3. Deaktiviere JavaScript und abonniere erneut. Du landest auf `/newsletter` mit der Zeile „Schau in deinen Posteingang“.
4. Sende mit über die Entwicklertools entferntem Häkchen ab. Die Einwilligungsmeldung erscheint, und nichts wird gespeichert.
5. Öffne **Submissions** im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
6. Wenn du einen Webhook hinzugefügt hast, öffne den Endpunkt unter **Webhooks**. Die Zustellung zeigt `succeeded` mit der Antwort deines Servers.
7. Öffne MailerLite. Der Abonnent ist unbestätigt in der Gruppe, und die Bestätigungs-E-Mail liegt in deinem Posteingang.

## Stolperfallen

| Problem | Was zu tun ist |
| --- | --- |
| Der Post ohne JavaScript zeigt eine 404 oder eine statische Seite | Das Formular hat an die vorgerenderte Seite gesendet, von der es kam. Baue die `action` aus `/newsletter` plus `queryString` und behalte `prerender = false` auf dieser Seite. |
| Nur das erste Formular auf einer Seite funktioniert | Das Skript hat `querySelector` für ein einzelnes Formular genutzt. Astro führt es einmal pro Seite aus, also geh in einer Schleife über jedes `form.signup`. |
| Der Build scheitert wegen einer fehlenden Umgebungsvariable | `astro:env` prüft das Schema zur Build-Zeit. Setze die Variable beim Host oder lokal in `.env`. |
| `402 plan_required` bei lokalen Tests | Der Post hatte keinen `localhost`-Origin. Prüf die `Origin`-Zeile im Handler und öffne die Site unter `localhost`. |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter **Spam**, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |

## Fragen

### Kann eine Astro Action auf einer vorgerenderten Seite laufen?

Aus einem Client-Skript aufgerufen ja, solange das Projekt einen Adapter hat: Die Action läuft auf ihrer eigenen On-Demand-Route. Ein einfacher HTML-Formular-Post braucht eine On-Demand-Seite, auf der er landen kann, deshalb schickt dieser Aufbau ihn an /newsletter.

### Brauche ich in Astro für ein Newsletter-Formular einen Adapter?

Nur für die serverseitige Version auf dieser Seite. Eine vollständig statische Astro-Site kann das Formular direkt aus dem Browser an Ikiform senden, was Ikiforms Menschlichkeitsprüfung beibehält und keinen Server braucht.

### Warum Anmeldungen per Webhook statt in der Action an MailerLite weiterleiten?

Die Action kann einen Bot nicht von einem Leser unterscheiden: Ikiform gibt beiden dieselbe Erfolgsantwort. Sein Webhook feuert nur für Anmeldungen, die die Spamprüfung bestehen, und ein Ausfall von MailerLite verzögert den Abonnenten dann über Webhook-Wiederholungen, statt das Absenden des Besuchers scheitern zu lassen.

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

- [Newsletter-Anmeldeformular](https://ikiform.com/de/guides/newsletter-signup.md): Baue ein Newsletter-Anmeldeformular mit eigenem Markup, speichere die Einwilligung, blocke Bot-Anmeldungen und gib jeden Abonnenten an Kit, MailerLite, beehiiv oder ein anderes Tool weiter.
- [Statische Site](https://ikiform.com/de/guides/newsletter-signup/static-site.md): Füge einer statischen Site mit Hugo, Jekyll, Eleventy oder Astro ein Newsletter-Formular hinzu, halte den API-Key des E-Mail-Tools aus der Seite heraus und bestätige im Tool.
- [MailerLite](https://ikiform.com/de/guides/newsletter-signup/mailerlite.md): Verbinde dein eigenes Anmeldeformular mit MailerLite: Ein Relay fügt neue Leser für das Double Opt-in unbestätigt hinzu und bittet einen Abonnenten nie zweimal um Bestätigung.
- [Newsletter-Formular mit Next.js](https://ikiform.com/de/guides/newsletter-signup/nextjs.md): Ein Newsletter-Formular im Footer mit Next.js: eine Server Action mit Einwilligungsprüfung und Turnstile, die an Ikiform sendet, plus ein Route Handler, der Leser zu Kit hinzufügt.
- [Double-Opt-in-Verfahren](https://ikiform.com/de/guides/newsletter-signup/double-opt-in.md): Ikiform sendet keine Bestätigungs-E-Mails, also läuft Double-Opt-in in deinem E-Mail-Tool: Ein Relay legt jede Anmeldung als unbestätigt an, und das Tool sendet den Link.
- [Kit (ConvertKit)](https://ikiform.com/de/guides/newsletter-signup/kit.md): Verbinde dein eigenes Newsletter-Formular mit Kit, über ein Relay, das den Abonnenten anlegt, ihn für die Incentive-E-Mail einem Kit-Formular hinzufügt und die Platzierung taggt.
