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

Veröffentlicht am 2026-10-25 · https://ikiform.com/de/guides/waitlist/nextjs

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](https://ikiform.com/de/guides/waitlist.md#form) 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:

|   | Browser sendet an Ikiform | Server Action mit Secret Key |
| --- | --- | --- |
| Wen Ikiform sieht | Die IP jedes Besuchers | Die IP deines Servers, für jeden Besucher |
| Rate-Limit pro Besucher | Das von Ikiform, pro IP | Musst du selbst hinzufügen, siehe unten |
| Erlaubte Domains und die Menschlichkeitsprüfung | Laufen | Übersprungen, weil der Schlüssel beweist, dass der Post von dir kommt |
| Wer sie aufrufen kann | Jeder, von deinen erlaubten Domains | Jeder, 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.

Diagramm: Wie eine Anmeldung durch die Next.js-Server-Action läuft

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`:

```bash
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](https://ikiform.com/de/docs/rate-limits.md) 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`:

```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`:

```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`:

```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`:

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

| Problem | Was zu tun ist |
| --- | --- |
| `403 forbidden` von Ikiform | Der Schlüssel stammt aus einem anderen Workspace, wurde widerrufen oder hat `submissions:write` nicht. Erstelle einen Secret Key mit diesem Scope. |
| `429 rate_limited` | Ein 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 gleichzeitig | Der 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ück | Die 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 ist | `showJoined` wurde aus einem Event-Handler aufgerufen. Ruf es innerhalb der Action des Formulars auf, wie `join` es tut. |

## Fragen

### Sollte eine Next.js-Warteliste aus dem Browser oder aus einer Server Action senden?

Beides funktioniert. Ein Browser-Post behält Ikiforms Limits pro Besucher, erlaubte Domains und die Menschlichkeitsprüfung. Eine Server Action verbirgt den Endpunkt, prüft die Eingabe zuerst und funktioniert vor der Hydration, aber du fügst ein eigenes Limit pro Besucher hinzu, weil jeder Post von deinem Server kommt.

### Funktioniert useOptimistic mit useActionState?

Ja. Setze den optimistischen Wert und ruf die Dispatch-Funktion aus useActionState in derselben Action auf, etwa in der action-Prop des Formulars. Der optimistische Wert erscheint, während die Action läuft, und weicht dem Action-State, wenn sie fertig ist.

### Brauche ich für eine Next.js-Warteliste eine Datenbank?

Nein. Die Server Action leitet jede Anmeldung an Ikiform weiter, das sie speichert, Spam filtert, dich per E-Mail benachrichtigt und Webhooks sendet. Deine App hält außer dem Rate-Limit-Zähler keinen Zustand.

### Ist Ikiform kostenlos?

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.

## Weitere Anleitungen

- [Warteliste für ein neues SaaS](https://ikiform.com/de/guides/waitlist.md): Baue eine Pre-Launch-Warteliste auf deiner eigenen Landingpage: das Formular, Spamschutz, Duplikate, Bestätigungs-E-Mails und ein sauberer Export am Launch-Tag.
- [Warteliste in reinem HTML](https://ikiform.com/de/guides/waitlist/html.md): Eine komplette Wartelisten-Seite in reinem HTML ohne Build, die ohne JavaScript funktioniert, nach dem Laden ein Danke inline zeigt und bei Bedarf zurückfällt.
- [Beta-Anmeldung mit Auswahl](https://ikiform.com/de/guides/waitlist/beta-signup.md): Ein Beta-Bewerbungsformular mit Auswahlfragen, die der Server durchsetzt, und ein Skript, das Bewerber bewertet und jede Kohorte aus der Liste auswählt.
- [Launch-Liste für Product Hunt](https://ikiform.com/de/guides/waitlist/product-hunt.md): Sammle Wochen vor Product Hunt eine Launch-Liste, markiere jede Anmeldung nach Kanal und sende die Launch-E-Mail in zwei Wellen, abgestimmt auf den Reset nach Pacific Time.
- [Warteliste für mobile Apps](https://ikiform.com/de/guides/waitlist/mobile-app.md): Eine Pre-Launch-Warteliste für eine iOS- oder Android-App, die fragt, welches Handy jemand nutzt, iPhone-Tester zu TestFlight schickt und einen Play-Closed-Test füllt.
- [Warteliste auf Framer](https://ikiform.com/de/guides/waitlist/framer.md): Zwei Wege, auf Framer Wartelisten-Anmeldungen zu sammeln: Framers Formular mit einem Webhook an Ikiform oder eine Code-Komponente, die Limits pro Besucher und Spamprüfungen behält.
