Warteliste für ein neues SaaS

Warteliste für ein neues SaaS7 Min. Lesezeit

So baust du eine Next.js-Warteliste mit einer Server Action

Eine Warteliste mit dem Next.js App Router: eine Server Action, die Anmeldungen mit Secret Key weiterleitet, useActionState, ein optimistisches Danke und ein Limit pro Besucher.

Preet SutharGründer, Ikiform

Schreib eine Server Action, die die E-Mail prüft, ein Limit pro Besucher anwendet und die Anmeldung mit einem Secret Key an dein Formular-Backend weiterleitet. Ruf sie über useActionState auf, damit Fehler als State zurückkommen, und umschließe sie mit useOptimistic, damit die Dankeszeile in dem Moment erscheint, in dem der Button gedrückt wird, und zurückgenommen wird, wenn der Post scheitert.

Die Hauptanleitung hat einen kurzen Next.js-Tab: eine Server Action und useActionState. Das reicht für eine ruhige Landingpage. Diese Seite geht weiter, für eine Warteliste, die du gleich breit teilen willst: ein sofortiges Danke, ehrliche Fehlermeldungen und ein Limit pro Besucher, das bei einem serverseitigen Post sonst verloren geht.

Was eine Server Action ändert

Eine Server Action hält den Secret Key auf deinem Server und lässt dich die E-Mail prüfen, bevor irgendetwas deine App verlässt. Sie verlegt außerdem jeden Post auf die IP-Adresse deines Servers, und das hat drei Folgen:

ItemBrowser sendet an IkiformServer Action mit Secret Key
Wen Ikiform siehtDie IP jedes BesuchersDie IP deines Servers, für jeden Besucher
Rate-Limit pro BesucherDas von Ikiform, pro IPMusst du selbst hinzufügen, siehe unten
Erlaubte Domains und die MenschlichkeitsprüfungLaufenÜbersprungen, weil der Schlüssel beweist, dass der Post von dir kommt
Wer sie aufrufen kannJeder, von deinen erlaubten DomainsJeder, der an deine Seite POSTen kann

Die letzte Zeile ist wichtig. Next.js behandelt eine Server Action als öffentlichen Endpunkt: Jeder kann sie mit einem selbstgebauten POST aufrufen, nicht nur dein Formular. Da der Secret Key Ikiforms Origin-Prüfungen überspringt, ist deine Action das einzige Tor, sie braucht also ein eigenes Limit.

Wie eine Anmeldung durch die Next.js-Server-Action läuftFormularuseOptimisticzeigt dasDankeServerActionprüftE-Mail,begrenztnach IPIkiformSecret Key,speichertdieAnmeldungAction-Stateeingetragenoder einFehlerFehlerzustandnichtsgesendet, dasFormularkommt zurückFormDataJSONErgebnisüber dem LimitWie eine Anmeldung durch die Next.js-Server-Action läuftFormularuseOptimistic zeigtdas DankeServer Actionprüft E-Mail,begrenzt nach IPIkiformSecret Key, speichertdie AnmeldungAction-Stateeingetragen oder einFehlerFehlerzustandnichtsgesendet, dasFormularkommt zurückFormDataJSONErgebnisüber dem Limit
  1. Der Besucher drückt den Button. useOptimistic ersetzt das Formular sofort durch die Dankeszeile.
  2. Die Server Action prüft die E-Mail und zählt die letzten Versuche des Besuchers nach IP.
  3. Innerhalb des Limits sendet sie die Anmeldung mit dem Secret Key an Ikiform.
  4. Das Ergebnis wird zum Action-State. Bei Erfolg bleibt das Danke; bei einem Fehler kommt das Formular mit der Meldung und der eingegebenen E-Mail zurück.
  5. Über dem Limit wird nichts gesendet, und der Fehler-State kommt sofort zurück.
Die optimistische Zeile hält nur, solange die Action läuft. Der Action-State entscheidet, was auf dem Bildschirm bleibt.

Bauen

Der Secret Key

Öffne im Dashboard API keys, erstelle einen Secret Key mit dem Scope submissions:write und speichere ihn als reine Server-Variable. Stell ihm nie NEXT_PUBLIC_ voran.

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

Ein Limit pro Besucher

Fünf Anmeldungen pro Minute pro IP sind großzügig für einen Menschen und nutzlos für ein Skript. Dieser Zähler lebt im Arbeitsspeicher des Servers. server-only lässt den Build scheitern, wenn eine Client-Komponente ihn importiert.

lib/rate-limit.ts
import "server-only";

const WINDOW_MS = 60_000;
const LIMIT = 5;
const hits = new Map<string, number[]>();

export function allow(key: string, now = Date.now()) {
  const recent = (hits.get(key) ?? []).filter((time) => now - time < WINDOW_MS);
  const allowed = recent.length < LIMIT;
  if (allowed) recent.push(now);
  if (hits.size > 10_000) hits.clear();
  hits.set(key, recent);
  return allowed;
}
  • Arbeitsspeicher wird nicht zwischen Serverinstanzen geteilt. Auf einem einzelnen Node-Server oder Container ist das exakt. Bei Serverless-Hosting hat jede Instanz ihre eigene Zählung, sieh es also als Bremsschwelle und verlege den Zähler in einen gemeinsamen Speicher wie Redis, wenn der Traffic wächst.
  • Vertrau nur dem IP-Header, den dein Host setzt. Vercel setzt x-forwarded-for; hinter Cloudflare liest du cf-connecting-ip. Ein Header, den der Besucher selbst setzen kann, ist gar kein Limit.

Die Server Action

app/waitlist/actions.ts
"use server";

import { headers } from "next/headers";
import { allow } from "@/lib/rate-limit";

export interface JoinState {
  status: "idle" | "joined" | "error";
  message: string;
  email?: string;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
  const email = String(formData.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return { status: "error", message: "Enter the email we should send your invite to.", email };

  const incoming = await headers();
  const visitor = incoming.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
  if (!allow(visitor)) return { status: "error", message: "Too many tries from this connection. Wait a minute.", email };

  let response: Response;
  try {
    response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        Origin: incoming.get("origin") ?? "",
      },
      body: JSON.stringify({
        email,
        ref: String(formData.get("ref") ?? "homepage").slice(0, 40),
        _gotcha: String(formData.get("_gotcha") ?? ""),
      }),
      signal: AbortSignal.timeout(8000),
    });
  } catch {
    return { status: "error", message: "We couldn't reach the list. Try again.", email };
  }

  if (response.status === 429) {
    const wait = response.headers.get("Retry-After") ?? "60";
    return { status: "error", message: `The list is busy. Try again in ${wait} seconds.`, email };
  }
  const result = await response.json().catch(() => null);
  if (result?.ok) return { status: "joined", message: "You're on the list. We'll email you on launch day." };
  return {
    status: "error",
    message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
    email,
  };
}
  • Die günstige Prüfung läuft zuerst. Eine falsche E-Mail kostet nie einen Request.
  • AbortSignal.timeout verhindert, dass ein langsames Netz den Button für immer im Wartezustand hält.
  • Ikiforms 429 wird separat gelesen, mit seinem Retry-After, weil bei allen Besuchern hinter einer IP das ganze Formular ausgelastet ist, nicht diese eine Person.
  • Jeder Fehler gibt die E-Mail zurück, damit das Eingabefeld sie wieder anzeigen kann. React setzt ein Formular nach einer Action zurück, und defaultValue setzt die Adresse wieder ein.
  • Der Origin-Header des Browsers wird weitergereicht, weil Ikiform kostenlose Testeinsendungen daran zählt und das eigene fetch eines Servers keinen sendet.
  • submit() aus dem SDK sendet keinen Authorization-Header, kann erlaubte Domains also nicht überspringen. Nutze hier fetch mit dem Schlüssel.

Das Formular, mit einem optimistischen Danke

app/waitlist/waitlist-form.tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { type JoinState, joinWaitlist } from "./actions";

const initial: JoinState = { status: "idle", message: "" };

export function WaitlistForm({ source }: { source: string }) {
  const [state, dispatch, pending] = useActionState(joinWaitlist, initial);
  const [shown, showJoined] = useOptimistic(
    state,
    (_current, email: string): JoinState => ({ status: "joined", message: `Adding ${email} to the list` }),
  );

  function join(formData: FormData) {
    showJoined(String(formData.get("email") ?? ""));
    dispatch(formData);
  }

  if (shown.status === "joined") {
    return (
      <p role="status" aria-busy={pending}>
        {shown.message}
      </p>
    );
  }

  return (
    <form action={join}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.email} />
      <input type="hidden" name="ref" value={source} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {shown.status === "error" ? <p role="alert">{shown.message}</p> : null}
      <button disabled={pending}>Join the waitlist</button>
    </form>
  );
}
  • useOptimistic muss innerhalb einer Action gesetzt werden. join ist die Action des Formulars, React führt sie also in einer Transition aus, und die optimistische Zeile hält, bis dispatch fertig ist.
  • Bei Erfolg wird der Action-State zu joined, und die Zeile bleibt, nun mit der endgültigen Meldung. Bei einem Fehler fällt der optimistische Wert weg, und das Formular kommt mit state.email und der Meldung zurück.
  • aria-busy sagt Screenreadern, dass die Zeile noch bestätigt wird.
Die Action in join zu verpacken macht sie zu einer Client-Funktion, dieses Formular braucht also JavaScript. Wenn Senden vor der Hydration wichtiger ist als das sofortige Danke, übergib dispatch direkt an action und lass useOptimistic weg; der Rest des Codes bleibt gleich.

Die Seite

app/waitlist/page.tsx
import { WaitlistForm } from "./waitlist-form";

export default async function Page({ searchParams }: PageProps<"/waitlist">) {
  const { ref } = await searchParams;
  const source = typeof ref === "string" ? ref.slice(0, 40) : "homepage";

  return (
    <main>
      <h1>Acme is almost here</h1>
      <p>Leave your email and we'll send you an invite the day it opens.</p>
      <WaitlistForm source={source} />
    </main>
  );
}

Die Seite liest ?ref= aus der URL und gibt es an das Formular weiter, ein Link wie /waitlist?ref=newsletter markiert also jede Anmeldung mit ihrer Quelle. PageProps ist der routenbewusste Typ, den Next.js erzeugt; das Lesen von searchParams rendert die Seite pro Request.

Prüfen, ob es funktioniert

  1. Führe next dev aus, öffne /waitlist?ref=test und melde dich an. Die Dankeszeile erscheint, bevor der Request fertig ist.
  2. Öffne im Dashboard Submissions. Die Anmeldung ist da, mit ref auf test.
  3. Sende sechsmal innerhalb einer Minute ab. Der sechste Versuch zeigt die Limit-Meldung, und nur fünf Einsendungen werden gespeichert.
  4. Setze IKIFORM_SECRET_KEY auf einen falschen Wert und sende ab. Die optimistische Zeile wird zurückgenommen, und das Formular zeigt den Fehler, mit deiner E-Mail noch im Feld.
  5. Prüfe das Postfach jedes Benachrichtigungsempfängers. Jeder bekommt eine E-Mail mit den Feldern, und Reply geht an den Absender, wenn das Formular ein email-Feld hat.
  6. 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.

Stolperfallen

ProblemWas zu tun ist
403 forbidden von IkiformDer Schlüssel stammt aus einem anderen Workspace, wurde widerrufen oder hat submissions:write nicht. Erstelle einen Secret Key mit diesem Scope.
429 rate_limitedEin Besucher hat mehr gesendet, als das Rate-Limit des Formulars erlaubt. Standard erlaubt 5 pro Minute und Besucher. Warte Retry-After ab oder wähle Relaxed oder Custom für Formulare, die von einer gemeinsamen IP aus senden.
Alle Besucher erreichen das Limit gleichzeitigDer IP-Header fehlt, also zählen alle als unknown. Lies den Header, den dein Host setzt, etwa cf-connecting-ip hinter Cloudflare.
Das Danke blitzt auf, und das Formular kommt ohne Meldung zurückDie Action hat geworfen, statt einen State zurückzugeben. Lass jeden Fehlerpfad einen JoinState zurückgeben, wie im Beispiel.
Eine Warnung, dass ein optimistisches Update außerhalb einer Transition passiert istshowJoined wurde aus einem Event-Handler aufgerufen. Ruf es innerhalb der Action des Formulars auf, wie join es tut.

Fragen

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