Warteliste für ein neues SaaS

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.

Preet SutharGründer, Ikiform

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:

ItemVorgerenderte LandingpageBei Bedarf gerenderte Seite
Ausgeliefert vonEinem CDN, als DateiDem Server oder der Funktion deines Adapters
<form action={actions.x}>Nicht erlaubt. Astro braucht die Seite on demandFunktioniert ohne JavaScript
?ref= in der URLFür den Server nicht sichtbar. Einmal gebaut, ohne QueryLesbar über Astro.url
Astro.clientAddressNicht verfügbarVerfü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.

Zwei Wege in eine Astro Action von einer statischen LandingpageMit JavaScriptOhne JavaScriptstatischeSeiteSkriptfüllt refaus derURLactions.joinWaitlist()läuft aufdem ServerIkiformSecret Key,speichertsieDankeszeilegleicheSeitestatischeSeiteFormularsendet an/join/joinon demand,Astro.callActionIkiformdieselbeAction/thanksWeiterleitungmit 303FormDataJSONErgebnisPOSTJSON303Zwei Wege in eine Astro Action von einer statischen LandingpageMit JavaScriptOhne JavaScriptstatische SeiteSkript füllt ref aus der URLactions.joinWaitlist()läuft auf dem ServerIkiformSecret Key, speichert sieDankeszeilegleiche Seitestatische SeiteFormular sendet an /join/joinon demand, Astro.callActionIkiformdieselbe Action/thanksWeiterleitung mit 303FormDataJSONErgebnisPOSTJSON303
  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. Die Dateien für diese Seite:

Projekt
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
npx astro add node
astro.config.mjs
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
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 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
{
  "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
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
---
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
---
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
---
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

HostAdapterDas Secret
Vercelnpx astro add vercelFüge IKIFORM_SECRET_KEY unter Environment Variables des Projekts hinzu
Netlifynpx astro add netlifyFüge ihn unter Site configuration › Environment variables hinzu
Cloudflarenpx astro add cloudflareFü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.

Stolperfallen

ProblemWas zu tun ist
403 von deiner eigenen Site beim einfachen PostAstros 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 SeiteEin 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 directDer 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_fieldDie Felddefinition des Formulars listet ref nicht auf. Füge es wie in der Definition oben hinzu.
402 plan_requiredDer 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

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