Newsletter-Anmeldeformular

Newsletter-Anmeldeformular8 Min. Lesezeit

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.

Preet SutharGründer, Ikiform

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

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:

WegWann er läuftWas der Leser sieht
Skript ruft actions.subscribe() aufJavaScript geladen, der übliche FallDie Statuszeile unter dem Formular ändert sich. Er bleibt auf dem Artikel
Formular sendet an /newsletter?_action=...JavaScript aus oder noch nicht geladenDie 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.

Zwei Wege, auf denen eine Footer-Anmeldung in Astro dieselbe Action erreichtmit JavaScriptohne JavaScriptArtikelvorgerendertesHTMLactions.subscribe()vomClient-SkriptAction-HandlerZod,dannfetchStatuszeilegleicheSeiteArtikeleinfacherFormular-Post/newsletterprerender=falseAction-HandlergleicherCodeErgebnisseitegetActionResultFormData/_actions/subscribe{ data, error }POST?_actionErgebnisZwei Wege, auf denen eine Footer-Anmeldung in Astro dieselbe Action erreichtmit JavaScriptohne JavaScriptArtikelvorgerendertes HTMLactions.subscribe()vom Client-SkriptAction-HandlerZod, dann fetchStatuszeilegleiche SeiteArtikeleinfacher Formular-Post/newsletterprerender = falseAction-Handlergleicher CodeErgebnisseitegetActionResultFormData/_actions/subscribe{ data, error }POST?_actionErgebnis
  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
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
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
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 im Tab Fields des Formulars ein.

Bauen

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

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 Relaxed oder Custom, und halte IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.

Die Footer-Komponente

src/components/SignupForm.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
---
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: 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
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 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

HostWas 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

ProblemWas zu tun ist
Der Post ohne JavaScript zeigt eine 404 oder eine statische SeiteDas 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 funktioniertDas 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 Umgebungsvariableastro:env prüft das Schema zur Build-Zeit. Setze die Variable beim Host oder lokal in .env.
402 plan_required bei lokalen TestsDer 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 anSie 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

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.