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.
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:
| 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.
- Mit JavaScript stoppt das Footer-Skript den normalen Post und ruft die Action mit den Daten des Formulars auf.
- Die Action validiert die Eingabe, sendet an Ikiform und gibt Daten oder einen Fehler an das Skript zurück, das die Statuszeile aktualisiert.
- Ohne JavaScript sendet der Browser das Formular an die On-Demand-Seite /newsletter, mit der Action im Query-String.
- Astro führt denselben Action-Handler aus, und die Seite liest das Ergebnis mit getActionResult und zeigt es an.
Projekt-Setup
src/
actions/index.ts
components/SignupForm.astro
layouts/Base.astro
pages/
newsletter.astro
api/ikiform.ts
astro.config.mjs
.envInstalliere 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.
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" }),
},
},
});IKIFORM_SECRET_KEY=ik_sk_your_secret_key
IKIFORM_WEBHOOK_SECRET=whsec_your_webhook_secret
MAILERLITE_API_KEY=your_mailerlite_token
MAILERLITE_GROUP_ID=123456789Erstelle 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
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 dieFormDataparsen. Die Checkbox durchläuft eine Boolean-Umwandlung, also ist angehakttrueund nicht angehaktfalse, wasrefinezur Einwilligungsmeldung macht.- Leere Text-Inputs kommen als
nullan, also nutzen optionale Feldernullish(), nichtoptional(). sourcefällt bei jedem Wert außerhalb der Liste auffooterzurück, sodass ein manipulierter Post keine Platzierungen erfinden kann.- Die
Origin-Zeile reicht den Origin des Browsers weiter, denn Ikiform erkennt kostenloselocalhost-Tests an diesem Header, und dasfetcheines Servers sendet keinen. - Die Fehler von Ikiform werden zu einem
ActionError. Ein429behält seine Bedeutung alsTOO_MANY_REQUESTS.
IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Footer-Komponente
---
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>queryStringist 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. isInputErrortrennt die feldbezogenen Meldungen von Zod von den Ablehnungen durch Ikiform, die als einfache Meldung ankommen.
Die On-Demand-Seite
---
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.
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
| 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
- Führe
astro buildaus und prüfe, dass deine Artikel in der Ausgabe weiterhin als vorgerendert gelistet sind und nur/newsletterund/api/ikiformon demand laufen. - Abonniere über den Footer eines Artikels. Die Statuszeile ändert sich, die URL nicht.
- Deaktiviere JavaScript und abonniere erneut. Du landest auf
/newslettermit der Zeile „Schau in deinen Posteingang“. - Sende mit über die Entwicklertools entferntem Häkchen ab. Die Einwilligungsmeldung erscheint, und nichts wird gespeichert.
- Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
- Wenn du einen Webhook hinzugefügt hast, öffne den Endpunkt unter Webhooks. Die Zustellung zeigt
succeededmit der Antwort deines Servers. - Ö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
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.
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.
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.
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.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.