Anleitungen16 Min. Lesezeit
So erstellst du eine Warteliste für ein neues SaaS
Baue eine Pre-Launch-Warteliste auf deiner eigenen Landingpage: das Formular, Spamschutz, Duplikate, Bestätigungs-E-Mails und ein sauberer Export am Launch-Tag.
Setze ein einfaches HTML-Formular auf deine Landingpage, das die E-Mail des Besuchers an ein Formular-Backend sendet, damit du vor dem Launch keinen Server betreibst. Leite jede Anmeldung per Webhook an das E-Mail-Tool weiter, mit dem du launchst, und lass dieses Tool eine eventuelle Bestätigung senden. Exportiere am Launch-Tag die Liste als CSV, entferne Duplikate nach E-Mail und sende eine Launch-E-Mail.
Eine Warteliste ist der günstigste Test einer SaaS-Idee. Wenn Fremde ihre E-Mail für etwas hinterlassen, das es noch nicht gibt, hast du einen Grund, es zu bauen, und eine Liste von Leuten, denen du Bescheid gibst, wenn es erscheint. Diese Anleitung baut eine auf deiner eigenen Landingpage, ohne Datenbank oder Server, und macht sie bereit für den Launch-Tag.
So funktioniert eine Warteliste
Jede Warteliste hat dieselben drei Teile: ein Formular auf deiner Seite, einen Ort, der jede Anmeldung speichert, und das Tool, mit dem du die Liste anschreibst. Hier sendet das Formular an Ikiform. Ikiform prüft den Post auf Spam, speichert ihn, benachrichtigt dich per E-Mail und sendet einen signierten Webhook. Ein kleines Relay oder ein Automatisierungstool übergibt die Adresse an dein E-Mail-Tool.
- Ein Besucher gibt auf deiner Landingpage seine E-Mail ein, und das Formular sendet an deinen Ikiform-Endpunkt.
- Ikiform prüft Honeypot, Spam-Score und Rate-Limit und speichert dann die Anmeldung.
- Die Anmeldung erscheint im Dashboard, wo du sie durchsuchen und die Liste als CSV exportieren kannst.
- Ikiform benachrichtigt dich und dein Team per E-Mail. Der Person, die sich angemeldet hat, schreibt es nie.
- Ikiform sendet einen signierten submission.created-Webhook.
- Ein Relay oder ein Automatisierungstool fügt die Adresse deinem E-Mail-Tool hinzu, das eine eventuelle Bestätigung und später die Launch-E-Mail sendet.
Vier Wege, eine Warteliste zu betreiben
Es gibt nicht das eine richtige Tool. Die Tabelle vergleicht die vier üblichen Setups danach, worin sie gut sind und was sie dich später kosten.
| Ansatz | Stark bei | Nachteile |
|---|---|---|
| Wartelisten-SaaS wie LaunchList oder Waitlister | Empfehlungslinks, Bestenlisten und Platz in der Warteschlange, sofort einsatzbereit. Eine gehostete Seite, falls du noch keine Site hast. | Deine Liste lebt in einem anderen Produkt. Das Widget sieht aus wie ihres, außer du zahlst oder gestaltest es um. Die Preise wachsen meist mit den Anmeldungen. |
| Eingebetteter Formular-Baukasten wie Typeform oder Tally | Ausgefeilte Abläufe mit einer Frage pro Schritt, ganz ohne Code. | Ein iframe, das nicht zu deiner Seite passt und eigene Skripte lädt. Branding, Antwortlimits und Webhooks hängen vom Tarif ab. |
| Deine eigene Datenbank und API-Route | Volle Kontrolle über die Daten und jede Logik, etwa Empfehlungscodes. | Du baust Spamschutz, Rate-Limits, Benachrichtigungen und Export selbst und brauchst einen Server, auch wenn die Landingpage statisch ist. |
| Formular-Backend wie Ikiform | Dein eigenes HTML auf jeder Site. Spamfilter, Speicherung, E-Mail-Benachrichtigungen, signierte Webhooks und CSV-Export ohne Server. | Keine Empfehlungs-Bestenliste und keine E-Mails an die Person, die sich anmeldet. Die kommen von deinem E-Mail-Tool. |
Nimm ein Wartelisten-SaaS, wenn Empfehlungsmechaniken der ganze Plan sind. Nimm ein Formular-Backend, wenn das Formular aussehen soll wie der Rest deiner Site, die Liste portabel bleiben soll und du E-Mails aus dem Tool senden willst, das du nach dem Launch ohnehin nutzt. Der Rest dieser Anleitung nutzt das Formular-Backend.
Das Formular
Frag zu Beginn nur nach einer E-Mail. Jedes zusätzliche Feld kostet Anmeldungen, und Fragen kannst du später per E-Mail an Leute stellen, die schon Ja gesagt haben. Wähle unten deinen Stack; die Wahl gilt für jedes Codebeispiel in diesen Anleitungen.
Füge das Formular dort ein, wo die Anmeldung erscheinen soll. Es sendet auf die einfache Art und leitet weiter, funktioniert also ganz ohne JavaScript.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="email">Email</label>
<input id="email" name="email" type="email" placeholder="you@company.com" autocomplete="email" required />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Join the waitlist</button>
</form>Damit Besucher auf der Seite bleiben, füge dieses Skript nach dem Formular ein. Es sendet mit fetch, ersetzt das Formular durch die Dankeszeile und zeigt die Feldfehler des Servers an.
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);
const labels = {
email: "Email",
};
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceWith("You're on the list. We'll email you at launch.");
return;
}
const fields = Object.entries(result.error.fields ?? {});
status.textContent = fields.length
? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
: result.error.message;
} catch {
status.textContent = "Couldn't reach the server. Try again.";
}
button.disabled = false;
});Mehr zu HTML? Lies So fügst du jeder Landingpage ein Wartelisten-Formular in reinem HTML hinzu.
Eine Server Action leitet die Anmeldung mit deinem Secret Key weiter, und useActionState zeigt das Ergebnis ohne Neuladen der Seite. Das Formular sendet trotzdem, wenn JavaScript noch nicht geladen ist.
"use server";
import { headers } from "next/headers";
export interface JoinState {
ok: boolean;
message: string;
email?: string;
}
export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
const email = String(formData.get("email") ?? "").trim();
if (!email.includes("@")) return { ok: false, message: "Enter the email we should send your invite to.", email };
const 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: (await headers()).get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return {
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
};
}"use client";
import { useActionState } from "react";
import { type JoinState, joinWaitlist } from "./actions";
const initial: JoinState = { ok: false, message: "" };
export function WaitlistForm() {
const [state, action, pending] = useActionState(joinWaitlist, initial);
if (state.ok) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required defaultValue={state.email} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Mehr zu Next.js? Lies So baust du eine Next.js-Warteliste mit einer Server Action.
Eine Astro Action mit accept: "form" validiert die E-Mail mit Zod und leitet sie weiter. Das Formular sendet ohne Client-JavaScript, die Seite muss also bei Bedarf gerendert werden: Füge einen Adapter hinzu und behalte prerender = false.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
joinWaitlist: defineAction({
accept: "form",
input: z.object({
email: z.email(),
ref: z.string().default("homepage"),
_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 ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (!result.ok) {
throw new ActionError({
code: "BAD_REQUEST",
message: result.error.fields?.email?.[0] ?? result.error.message,
});
}
return { joined: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.joinWaitlist);
---
{result?.data?.joined ? (
<p role="status">You're on the list. We'll email you on launch day.</p>
) : (
<form method="POST" action={actions.joinWaitlist}>
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Enter a valid email address." : result.error.message}</p>
)}
<button>Join the waitlist</button>
</form>
)}Mehr zu Astro? Lies Eine Warteliste in Astro bauen.
Eine createServerFn-POST-Funktion empfängt die FormData, prüft sie in .validator() und leitet sie vom Server weiter. Die Route ruft sie über useServerFn auf.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const joinWaitlist = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) throw new Error("Enter the email we should send your invite to.");
return {
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
const origin = getRequestHeader("origin");
const 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 ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return { ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) };
});
export const Route = createFileRoute("/")({ component: Home });
function Home() {
const join = useServerFn(joinWaitlist);
const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setState(await join({ data }));
} catch (error) {
setState({ ok: false, message: error instanceof Error ? error.message : "Couldn't join the list. Try again." });
} finally {
setPending(false);
}
}
if (state?.ok) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state?.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Mehr zu TanStack Start? Lies Eine Warteliste in TanStack Start bauen.
Die action der Route leitet den Post weiter, und actionData bringt das Ergebnis zurück zur Komponente. data() behält bei Fehlern den Statuscode von Ikiform.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/home";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
const 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: request.headers.get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return data(
{ ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}
export default function Home({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.ok) return <p role="status">{actionData.message}</p>;
return (
<Form method="post">
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</Form>
);
}Mehr zu React Router? Lies Eine Warteliste in React Router bauen.
Eine Standard-Form-Action leitet den Post weiter und gibt bei Fehlern fail() mit der Meldung zurück. use:enhance sendet ohne Neuladen und behält die eingegebene E-Mail im Eingabefeld. Deklariere in SvelteKit 3 IKIFORM_SECRET_KEY in src/env.ts mit defineEnvVars, damit $app/env/private ihn lesen kann.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) return fail(400, { email, message: "Enter the email we should send your invite to." });
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: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
return fail(response.status, {
email,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
</script>
{#if form?.joined}
<p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update();
pending = false;
};
}}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" required value={form?.email ?? ""} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{form.message}</p>
{/if}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
{/if}Mehr zu SvelteKit? Lies Eine Warteliste in SvelteKit bauen.
Eine Server-Route hält den Secret Key vom Browser fern. Setze NUXT_IKIFORM_SECRET_KEY in der Umgebung, und Nuxt füllt daraus runtimeConfig.ikiformSecretKey.
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; ref?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: body.email?.trim() ?? "",
ref: body.ref ?? "homepage",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/waitlist", {
method: "POST",
body: { email: email.value, ref: "homepage", _gotcha: trap.value },
});
status.value = "joined";
} catch (failure) {
error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
status.value = "idle";
}
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else @submit.prevent="join">
<label for="email">Email</label>
<input id="email" v-model="email" name="email" type="email" required placeholder="you@company.com" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>Mehr zu Nuxt? Lies Eine Warteliste in Nuxt bauen.
Eine Single-Page-App hat keinen eigenen Server, das Formular sendet also direkt aus dem Browser an Ikiform. Schreib nie einen Secret Key in diesen Code; stattdessen schützt Allowed domains das Formular.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function WaitlistForm() {
const [status, setStatus] = useState<"idle" | "sending" | "joined">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setStatus("joined");
return;
}
setError(result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
}
setStatus("idle");
}
if (status === "joined") return <p role="status">You're on the list. We'll email you on launch day.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Derselbe browserseitige Post in einer Vue-Single-File-Component. Das Formular behält seine action, funktioniert also auch, wenn das Skript nicht lädt.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "joined";
return;
}
error.value = result.error.fields?.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
}
status.value = "idle";
}
</script>
<template>
<p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="join">
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>- Der Endpunkt ist die URL deines Formulars. Ersetze
frm_xxxxxxxxxxxxxxxxdurch die ID aus dem Dashboard. name="email"ist die Feld-ID. Lass sie beiemail: Ikiform nutzt sie als Reply-to-Adresse der Benachrichtigungs-E-Mails und prüft sie gegen Wegwerf-E-Mail-Domains.- Das versteckte Eingabefeld
_gotchaist der Honeypot. Menschen sehen es nie. Bots, die jedes Eingabefeld ausfüllen, markieren sich selbst als Spam, und die Server-Versionen reichen es unverändert weiter. - Das versteckte Eingabefeld
refhält fest, woher die Anmeldung kam. Ändere seinen Wert pro Seite oder Kampagne. - Mit einer Felddefinition im Tab Fields wird ein Eingabefeld, das sie nicht auflistet, mit
422und einemunknown_field-Fehler abgelehnt, liste alsorefebenso auf wieemail, wie unten. - Ein einfacher HTML-Post leitet den Browser nach dem Erfolg weiter. Setze Redirect after submit in den Einstellungen des Formulars auf deine eigene Dankeseite, sonst zeigt Ikiform seine gehostete.
{
"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 } }
]
}IKIFORM_SECRET_KEY in reinen Server-Umgebungsvariablen.Die Server-Versionen reichen den Origin-Header des Browsers weiter, weil Ikiform kostenlose localhost-Tests daran erkennt und das eigene fetch eines Servers keinen sendet.
Die Framework-Anleitungen in der Doku gehen tiefer: Next.js, Astro, SvelteKit, Nuxt, React, Vue und reines HTML. Das SDK kapselt den Browser-Request als submit(), und die Wartelisten-Formularvorlage erzeugt die Version mit vier Feldern.
Felder, nach denen es sich zu fragen lohnt
E-Mail ist das einzige Feld, das du brauchst. Wenn du die Liste vor dem Launch sortieren willst, füge höchstens zwei weitere hinzu. Probier unten die Version mit vier Feldern aus: Sie führt Ikiforms Validator in deinem Browser aus und zeigt, was gespeichert würde, oder den 422, den du zurückbekämst.
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
ref, das deine Seite mit einem versteckten Eingabefeld statt eines sichtbaren Felds füllt. Füge es im Tab Fields des Formulars ein, und Ikiform setzt es auf dem Server durch.| Feld | Warum du fragen würdest | Beachte |
|---|---|---|
email | Die Liste selbst | Pflicht. Nutze type="email", damit Handys die richtige Tastatur zeigen |
name | Eine persönliche Launch-E-Mail | Optional. Ein Vorname reicht |
role | Gründer zuerst einladen oder Entwicklern eine andere E-Mail schreiben | Ein select hält die Antworten sauber zum Filtern |
source | Erfahren, welcher Kanal funktioniert, bevor du Geld dafür ausgibst | Freitext ist ehrlich, aber unordentlich. Ein verstecktes ref-Eingabefeld ist sauberer, siehe unten |
ref | Anmeldungen pro Seite oder Kampagne zählen, ohne zu fragen | Ein verstecktes Eingabefeld. Optionaler text mit max_length 40 |
Um ohne Nachfrage zu verfolgen, woher Anmeldungen kommen, füge pro Seite oder Kampagne ein verstecktes Eingabefeld hinzu. Namen, die mit _ beginnen, sind Anweisungen an Ikiform und werden nie gespeichert, nutze also einen einfachen Namen wie ref:
<input type="hidden" name="ref" value="homepage" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />Jede Einsendung hält außerdem Origin und Referrer der Seite fest, du siehst also auch ohne ref, von welcher Seite eine Anmeldung kam. Das Eingabefeld _redirect schickt den Browser auf deine eigene Dankeseite; es funktioniert nur für eine https:-URL auf einer der Allowed domains des Formulars.
true mit dem Zeitstempel der Einsendung, was festhält, wann die Person zugestimmt hat. Die Newsletter-Vorlage hat eine.Halte Bots von der Liste fern
Ein öffentliches Formular mit E-Mail-Feld zieht innerhalb von Tagen Bots an. Falsche Anmeldungen blähen deine Zahlen auf, und schlimmer noch, deine Launch-E-Mail landet in Spam-Fallen, die deiner Absender-Reputation schaden. Ikiform stapelt vier Schutzschichten; die ersten beiden sind ohne Einrichtung aktiv.
| Abwehr | Was sie stoppt | Einrichtung |
|---|---|---|
| Honeypot | Einfache Bots, die jedes Eingabefeld ausfüllen | Das versteckte Eingabefeld _gotcha im Formular oben |
| Spam-Bewertung | Wegwerfadressen (+2), identische Wiederholungen (+3), Links und bekannte Spam-Phrasen. Ab 5 wird eine Anmeldung als Spam gespeichert | Keine |
| Rate-Limits | Skripte, die in einer Schleife senden | Standardmäßig aktiv: Standard, 5 pro Minute pro Besucher, und 300 pro Minute für das ganze Formular |
| Menschlichkeitsprüfung | Bots, die einen echten Browser ausführen | Setze Spam check auf Ikiform. Es führt Cloudflare Turnstile aus, ohne dass du Schlüssel verwalten musst |
Spam wird mit einer normalen Erfolgsantwort gespeichert, ein Bot erfährt also nichts. Er löst keine E-Mail und keinen Webhook aus und zählt nicht zu deiner Nutzung. Schau im ersten Monat einmal pro Woche in den Filter Spam im Dashboard; landet dort eine echte Anmeldung, markiere sie als kein Spam, und ihr Webhook geht dann raus.
Füge deine Domain auch unter Allowed domains hinzu. Posts von anderen Sites werden dann als Spam markiert oder mit 403 origin_not_allowed abgelehnt, wenn du Reject wählst. Mit aktiver Menschlichkeitsprüfung braucht das HTML-Formular keine Änderung. Die fetch-Version muss zuerst mit challenge() aus @ikiform/sdk ein Token anfordern; die Spam-Doku zeigt die vier Zeilen.
Dieselbe Person, zweimal
Leute melden sich erneut an, aus einem anderen Tab, von einem zweiten Gerät oder eine Woche später, weil sie es vergessen haben. Ikiform speichert jeden angenommenen Post und führt Wiederholungen nach E-Mail weder zusammen noch lehnt es sie ab. Das passiert mit einer Wiederholung:
| Fall | Ergebnis |
|---|---|
| Gleiche E-Mail, andere Felder oder mehr als 24 Stunden später | Eine zweite, normale Einsendung |
| Dieselben Felder erneut innerhalb von 24 Stunden | +3 auf den Spam-Score, unter 5, also eine zweite normale Einsendung |
| Eine identische Wiederholung von einer Wegwerf-E-Mail-Domain | +3 und +2, das ergibt 5: als Spam gespeichert |
Rechne also mit einigen Duplikaten und entferne sie dort, wo die Liste landet. Drei Stellen funktionieren, vom geringsten zum größten Aufwand:
- Im E-Mail-Tool. Die meisten E-Mail-Tools identifizieren Kontakte über die Adresse, dieselbe Adresse zweimal hinzuzufügen aktualisiert also einen Kontakt, statt zwei anzulegen.
- Beim Export. Entferne doppelte E-Mails aus der CSV, bevor du sie irgendwo importierst. Zwei Wege stehen unten.
- In deinem Relay. Schreib die Adresse klein und trimme sie, und füge sie dann mit einem Unique-Constraint oder einem Upsert ein, sodass eine Wiederholung nichts bewirkt.
Willst du verhindern, dass ein Doppelklick überhaupt zwei Zeilen erzeugt? Sende mit der fetch-Version pro Seitenaufruf einen Idempotenzschlüssel. Das stoppt versehentliche Wiederholungen; es hindert niemanden daran, sich absichtlich zweimal anzumelden.
Anmeldungen bestätigen
Ikiform schreibt der Person, die das Formular ausfüllt, keine E-Mail. Benachrichtigungs-E-Mails gehen nur an dich und dein Team, es gibt also keinen Autoresponder, keine Bestätigungs-E-Mail und kein Double-Opt-in. Sende diese aus deinem E-Mail-Tool, ausgelöst durch einen Webhook. Das ist Absicht: Das Tool, das deine Launch-E-Mail sendet, sollte auch die Bestätigung senden, damit Abmeldungen und Bounces an einem Ort erfasst werden.
Zwei Wege, Ikiform damit zu verbinden:
- Ohne Code. Füge einen Webhook-Endpunkt hinzu, der auf ein Automatisierungstool zeigt, und nutze dann dessen Action für dein E-Mail-Tool. Siehe Zapier, Make oder n8n.
- Ein kleines Relay. Ein Route-Handler auf einem beliebigen Node-Host verifiziert die Signatur und ruft die API deines E-Mail-Tools auf. Das Beispiel unten ist eine Next.js-Route; derselbe Code läuft in einem Cloudflare Worker.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const email = String(event.data.fields.email ?? "").trim().toLowerCase();
if (!email) return new Response(null, { status: 204 });
const response = await fetch(process.env.LIST_API_URL!, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.LIST_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
email,
name: event.data.fields.name ?? null,
tags: ["waitlist", String(event.data.fields.role ?? "unknown")],
}),
});
if (response.status === 409) return new Response(null, { status: 204 });
if (!response.ok) return new Response(`List answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}verifyWebhookprüft Signatur und Zeitstempel, damit niemand sonst über diese URL Adressen hinzufügen kann.- Ersetze das
fetchdurch den Kontakt-hinzufügen-Aufruf deines E-Mail-Tools. Die meisten nehmen eine E-Mail und eine Liste oder einen Tag und senden ihre eigene Bestätigungs-E-Mail, wenn in diesem Tool Double-Opt-in aktiv ist. - Mit
204zu antworten sagt Ikiform, dass die Zustellung geklappt hat. Ein502lässt Ikiform es erneut versuchen, mit derselbenwebhook-id, ein kurzer Ausfall deines E-Mail-Tools kostet also keine Anmeldungen.
Füge den Endpunkt im Dashboard unter Webhooks hinzu und kopiere sein whsec_-Secret in IKIFORM_WEBHOOK_SECRET. Ein Workspace kann 3 Endpunkte haben. Die Webhook-Doku behandelt Wiederholungen und die Payload, und dein eigener Server hat eine Version, die jede Zustellung genau einmal speichert.
Launch-Tag
- Die Seite geht live, und das Formular sendet an Ikiform.
- Teile die Seite jede Woche und lies die neuen Anmeldungen und ihre Antworten.
- Exportiere eine Woche vor dem Launch die Liste als CSV, entferne Duplikate und teile sie nach Rolle auf.
- Sende am Launch-Tag eine E-Mail aus deinem E-Mail-Tool an die Liste.
Die Liste exportieren
Öffne im Dashboard Submissions, wähle das Wartelisten-Formular, setze den Status auf Received und den Zeitraum auf All time und wähle dann Export CSV. Die Spalten sind id, created_at, form und status, dann eine Spalte pro Feld. Die Datei ist UTF-8 mit Byte Order Mark, Umlaute und Akzente überstehen also Excel. Ein Export umfasst bis zu 100.000 Zeilen; darüber hinaus blätterst du über die REST-API.
Um Duplikate in Google Sheets zu entfernen, importiere die CSV und nutze diese Formel in einem leeren Tabellenblatt, mit der E-Mail in Spalte E:
=SORTN(A2:F, 9^9, 2, LOWER(TRIM(E2:E)), TRUE)Sie behält eine Zeile pro Adresse, ignoriert Groß- und Kleinschreibung sowie Leerzeichen und sortiert nach E-Mail. Oder führe das hier mit Node in dem Ordner aus, der den Export enthält. Es behält die erste Zeile pro Adresse und setzt voraus, dass Werte keine Kommas enthalten, was für ein reines E-Mail-Formular zutrifft:
import { readFileSync, writeFileSync } from "node:fs";
const [header = "", ...rows] = readFileSync("submissions.csv", "utf8").replace(/^\uFEFF/, "").trim().split(/\r?\n/);
const columns = header.split(",");
const emailAt = columns.indexOf("email");
const seen = new Set();
const unique = rows.filter((row) => {
const email = row.split(",")[emailAt]?.trim().toLowerCase();
if (!email || seen.has(email)) return false;
seen.add(email);
return true;
});
writeFileSync("waitlist.csv", [header, ...unique].join("\n"));
console.log(`${rows.length} rows, ${unique.length} unique emails`);Die Launch-E-Mail senden
Sende sie aus deinem E-Mail-Tool, nicht aus deinem eigenen Postfach. Ein privates Postfach, das gleichzeitig an Hunderte Adressen sendet, sieht für Mail-Anbieter wie Spam aus, und sobald du sendest, brauchst du eine Abmeldefunktion. Halte sie kurz:
Subject: Acme is live. You're in.
Hi Ada,
You joined the Acme waitlist in the spring. Today it's open.
Sign in here: https://acme.example/start
You get the first 30 days free because you waited.
Reply to this email if anything breaks. It comes to me.
Ada, founder of Acme- Sag, was live ist, und verlinke direkt auf die Registrierungs- oder Anmeldeseite.
- Belohne das Warten, etwa mit einer längeren Testphase oder einem Rabatt, und sag es einmal.
- Sende sie von einer Person. Antworten sind das beste frühe Feedback, das du bekommst.
- Gib Gründern und Entwicklern unterschiedliche erste Zeilen, wenn du
rolegesammelt hast. Genau dafür sammelst du es.
Wähle deinen Fall
Die Schritte oben funktionieren überall. Diese Seiten behandeln, was sich für einen bestimmten Launch oder Website-Baukasten ändert.
- Launch-Liste für Product HuntSammle 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.
- Beta-Anmeldung mit AuswahlEin Beta-Bewerbungsformular mit Auswahlfragen, die der Server durchsetzt, und ein Skript, das Bewerber bewertet und jede Kohorte aus der Liste auswählt.
- Warteliste für mobile AppsEine 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 mit Next.jsEine 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.
- Warteliste auf FramerZwei 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.
- Warteliste auf WebflowRichte einen Webflow Form Block auf Ikiform aus, markiere Anmeldungen mit einem versteckten Feld nach Quelle und behalte mit einem kurzen Skript Webflows Erfolgszustand für jedes Formular.
- Warteliste auf CarrdSammle Wartelisten-Anmeldungen auf einer Carrd-Seite: ein Send-to-URL-Formular auf Pro Plus mit UTM-Tracking oder ein eingebettetes HTML-Formular mit Honeypot auf Pro Standard.
- Warteliste in reinem HTMLEine 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.
- Astro-WartelisteEine 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.
- Warteliste mit TanStack StartEine TanStack-Start-Warteliste: Eine Server-Route beantwortet sowohl einen einfachen Formular-Post als auch fetch, und validateSearch trägt Quelle und Ergebnis in der URL.
- React-Router-WartelisteEine React-Router-Warteliste im Footer jeder Seite: Ein Fetcher sendet an eine Route-Action, markiert jede Anmeldung mit ihrer Seite und funktioniert ohne JavaScript.
- Warteliste mit SvelteKitEine SvelteKit-3-Warteliste: Ein Hook behält das erste ?ref= in einem Cookie, eine benannte Form-Action leitet die Anmeldung weiter, und wiederkehrende Besucher sehen, dass sie dabei sind.
- Nuxt-WartelisteEine Nuxt-Warteliste auf einer vorgerenderten Landingpage: Eine Nitro-Route nimmt $fetch und einfache Formular-Posts an, prüft sie mit Zod und leitet ohne JavaScript weiter.
Prüfen, ob es funktioniert
Geh das durch, bevor du die Seite teilst. 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.
- Füge unter Allowed domains des Formulars die Domain deiner Site hinzu, plus
localhost, während du entwickelst. Lass das Feld leer, um Posts von überall anzunehmen. - Füll das Formular auf deiner Seite aus und sende es ab. Du landest auf deiner Dankeseite oder der gehosteten von Ikiform.
- Öffne Submissions im Dashboard. Die neue Einsendung ist da, mit jedem Feld, das du gesendet hast.
- 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. - Wenn du einen Webhook hinzugefügt hast, öffne den Endpunkt unter Webhooks. Die Zustellung zeigt
succeededmit der Antwort deines Servers. - 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. - Nichts angekommen? Stell den Statusfilter im Dashboard auf Spam. Spam bekommt dieselbe Erfolgsantwort wie eine echte Einsendung.
- Melde dich zweimal mit derselben Adresse an und prüfe, dass dein E-Mail-Tool weiterhin einen Kontakt zeigt.
Fehlerbehebung
| Problem | Was zu tun ist |
|---|---|
403 origin_not_allowed, oder jeder Post landet im Spam | Die Domain der Seite steht nicht unter Allowed domains des Formulars. Füge sie hinzu, plus localhost für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt. |
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. |
403 challenge_required | Die Spamprüfung des Formulars steht auf Ikiform, und ein fetch-Post hatte kein Token. Hol dir vor jedem Absenden eins mit challenge(formId) aus @ikiform/sdk oder schalte die Prüfung aus. |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter Spam, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |
| Der Browser landet auf der Dankeseite von Ikiform statt auf deiner | _redirect wird ignoriert, außer seine Domain steht unter Allowed domains und es ist eine https:-URL. Füge die Domain hinzu oder setze stattdessen Redirect after submit in den Einstellungen des Formulars. |
| Dieselbe E-Mail erscheint zweimal | Zu erwarten. Ikiform dedupliziert nicht nach E-Mail. Entferne Wiederholungen beim Export oder mach in deinem Relay oder E-Mail-Tool einen Upsert nach E-Mail. |
| Anmeldungen kommen in Ikiform an, aber nicht im E-Mail-Tool | Öffne den Endpunkt unter Webhooks und lies das Zustellungsprotokoll. Ein 400 heißt, das Secret ist falsch; ein 502 mit dem Status deines Tools heißt, das Tool hat den Kontakt abgelehnt. |
402 test_limit_reached | Der Workspace hat keinen Tarif und hat seine 50 kostenlosen localhost-Tests diesen Monat aufgebraucht. Wähle einen Tarif oder nutze Send test im Dashboard. |
Fragen
Keinen eigenen Server. Das Formular braucht ein Ziel für den Post, und genau das ist ein Formular-Backend: Richte die action des Formulars auf deinen Ikiform-Endpunkt aus, und es speichert, filtert und leitet jede Anmeldung weiter. Deine Landingpage kann statisch bleiben.
Nein. Ikiform schreibt dir und deinem Team, nicht der Person, die sich anmeldet, es gibt also keine Bestätigungs- oder Double-Opt-in-E-Mail. Sende jede Anmeldung per Webhook an dein E-Mail-Tool und aktiviere dort Double-Opt-in.
Behalte den versteckten _gotcha-Honeypot, lass das Standard-Rate-Limit aktiv, füge deine Domain unter Allowed domains hinzu und setze die Spamprüfung auf Ikiform, für eine Cloudflare-Turnstile-Prüfung ohne Schlüsselverwaltung. Wegwerfadressen erhöhen außerdem den Spam-Score.
Das kann passieren. Ikiform behält jeden angenommenen Post und führt Wiederholungen nach E-Mail nicht zusammen. Dedupliziere dort, wo die Liste lebt: in deinem E-Mail-Tool, in einer Unique-Spalte deiner Datenbank oder in der CSV, bevor du sie importierst.
Nicht mit Ikiform allein. Es hat keine Empfehlungslinks oder Bestenlisten. Du kannst einen Empfehlungscode in einem versteckten Feld mitgeben und selbst zählen, oder ein Wartelisten-Produkt nutzen, das um Empfehlungen herum gebaut ist, wenn das für deinen Launch zentral ist.
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.