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.
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:
| Item | 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.
- Der Besucher drückt den Button. useOptimistic ersetzt das Formular sofort durch die Dankeszeile.
- Die Server Action prüft die E-Mail und zählt die letzten Versuche des Besuchers nach IP.
- Innerhalb des Limits sendet sie die Anmeldung mit dem Secret Key an Ikiform.
- 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.
- Über dem Limit wird nichts gesendet, und der Fehler-State kommt sofort zurück.
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.
IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_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.
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 ducf-connecting-ip. Ein Header, den der Besucher selbst setzen kann, ist gar kein Limit.
Die Server Action
"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.timeoutverhindert, dass ein langsames Netz den Button für immer im Wartezustand hält.- Ikiforms
429wird separat gelesen, mit seinemRetry-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
defaultValuesetzt die Adresse wieder ein. - Der
Origin-Header des Browsers wird weitergereicht, weil Ikiform kostenlose Testeinsendungen daran zählt und das eigenefetcheines Servers keinen sendet. submit()aus dem SDK sendet keinenAuthorization-Header, kann erlaubte Domains also nicht überspringen. Nutze hierfetchmit dem Schlüssel.
Das Formular, mit einem optimistischen Danke
"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>
);
}useOptimisticmuss innerhalb einer Action gesetzt werden.joinist die Action des Formulars, React führt sie also in einer Transition aus, und die optimistische Zeile hält, bisdispatchfertig 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 mitstate.emailund der Meldung zurück. aria-busysagt Screenreadern, dass die Zeile noch bestätigt wird.
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
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
- Führe
next devaus, öffne/waitlist?ref=testund melde dich an. Die Dankeszeile erscheint, bevor der Request fertig ist. - Öffne im Dashboard Submissions. Die Anmeldung ist da, mit
refauftest. - Sende sechsmal innerhalb einer Minute ab. Der sechste Versuch zeigt die Limit-Meldung, und nur fünf Einsendungen werden gespeichert.
- Setze
IKIFORM_SECRET_KEYauf 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. - 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. - 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
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.
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.
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.
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.