Warteliste für ein neues SaaS8 Min. Lesezeit
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.
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 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:
| Item | 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.
- Die Landingpage ist einfaches HTML vom CDN. Wenn ihr Skript lädt, kopiert es ?ref= oder ?utm_source= in das versteckte ref-Eingabefeld.
- 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.
- Das Skript ersetzt das Formular durch eine Dankeszeile oder zeigt den Fehler unter dem Eingabefeld.
- Ohne JavaScript sendet der Browser das Formular an /join, das on demand rendert.
- /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.
Das Projekt einrichten
Die Produkteinrichtung, etwa das Anlegen des Formulars und des Secret Keys, steht in der Astro-Anleitung in der Doku. Die Dateien für diese Seite:
src/
actions/index.ts
components/WaitlistForm.astro
pages/index.astro
pages/join.astro
pages/thanks.astro
astro.config.mjs
.envEin 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.
npx astro add nodeimport { 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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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:
{
"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
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"parstFormData. Leere Eingabefelder kommen alsnullan,refund der Honeypot sind alsonullish(), und ein fehlendesrefwird zudirect.refmuss aus Kleinbuchstaben, Ziffern,-oder_bestehen, bis zu 40 Zeichen. Alles andere wird alsdirectgespeichert, ein präparierter Link kann deinen Export also nicht mit Müll füllen. Das Limit entspricht dermax_lengthin der Definition.z.email()ist das E-Mail-Schema auf oberster Ebene von Zod 4, exportiert ausastro/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 eigenefetchder 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
ActionErrormit einem Code, der zum Problem passt. Das Skript und/joinlesen beideerror.message.
Die Formular-Komponente
---
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
actiondes 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=newsletterwird mitnewslettermarkiert. - 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
idzu verweisen, damit zwei Kopien auf einer Seite nicht kollidieren.
Die Landingpage und /join
---
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:
---
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
- Führe
astro devaus und öffnehttp://localhost:4321/?ref=test. Melde dich an. Das Formular wird ohne Neuladen zur Dankeszeile. - Öffne im Dashboard Submissions. Die Anmeldung ist da, mit
refauftest. - Schalte im Browser JavaScript aus, lade neu und melde dich erneut an. Du landest auf
/thanks, und die neue Anmeldung hatrefaufdirect. - Sende mit ausgeschaltetem JavaScript
not-an-emailab./joinzeigt das Formular erneut mit dem E-Mail-Fehler. - 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. - Posts aus
astro devtragen einenlocalhost-Origin, zählen also als Testeinsendungen. Ikiform hat keinen kostenlosen Tarif. Testen ist kostenlos: Ein Workspace ohne Tarif nimmt 50 Testeinsendungen pro Monat vonlocalhostan. 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.
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
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.
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.
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.
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.