Guides17 min read
How to create a waitlist for a new SaaS
Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
Put a plain HTML form on your landing page that posts the visitor's email to a form backend, so you don't run a server before launch. Forward each signup by webhook to the email tool you'll launch with, and let that tool send any confirmation. On launch day, export the list as CSV, remove duplicates by email, and send one launch email.
A waitlist is the cheapest test of a SaaS idea. If strangers leave their email for something that doesn't exist yet, you have a reason to build it, and a list of people to tell when it ships. This guide builds one on your own landing page, without a database or a server, and gets it ready for launch day.
How a waitlist works
Every waitlist has the same three parts: a form on your page, somewhere that stores each signup, and the tool you'll email the list from. Here, the form posts to Ikiform. Ikiform checks the post for spam, stores it, emails you, and sends a signed webhook. A small relay or an automation tool hands the address to your email tool.
- A visitor enters their email on your landing page, and the form posts to your Ikiform endpoint.
- Ikiform checks the honeypot, the spam score and the rate limit, then stores the signup.
- The signup shows up in the dashboard, where you can search it and export the list as CSV.
- Ikiform emails you and your team. It never emails the person who signed up.
- Ikiform sends a signed submission.created webhook.
- A relay or an automation tool adds the address to your email tool, which sends any confirmation and, later, the launch email.
Four ways to run a waitlist
There's no single right tool. The table compares the four common setups by what they're good at and what they cost you later.
| Approach | Good at | Trade-offs |
|---|---|---|
| Waitlist SaaS, such as LaunchList or Waitlister | Referral links, leaderboards and position in line, out of the box. A hosted page if you have no site yet. | Your list lives in another product. The widget looks like theirs unless you pay or restyle it. Pricing usually grows with signups. |
| Form builder embed, such as Typeform or Tally | Polished one-question flows, with no code at all. | An iframe that doesn't match your page and loads its own scripts. Branding, response limits and webhooks depend on the plan. |
| Your own database and API route | Full control over the data and any logic, such as referral codes. | You build spam protection, rate limits, notifications and export, and you need a server even if the landing page is static. |
| Form backend, such as Ikiform | Your own HTML on any site. Spam filtering, storage, email alerts, signed webhooks and CSV export with no server. | No referral leaderboard and no emails to the person who signs up. Those come from your email tool. |
Pick a waitlist SaaS if referral mechanics are the whole plan. Pick a form backend if you want the form to look like the rest of your site, keep the list portable, and send email from the tool you'll use after launch anyway. The rest of this guide uses the form backend.
The form
Ask for an email and nothing else to start. Every extra field costs signups, and you can ask questions later, by email, of people who already said yes. Pick your stack below; the choice carries over to every code sample in these guides.
Paste the form where the signup should appear. It posts the plain way and redirects, so it works with no JavaScript at all.
<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>To keep visitors on the page, add this script after the form. It posts with fetch, swaps the form for the thank-you line and shows the server's field errors.
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;
});Going further with HTML? Read How to add a waitlist form in plain HTML to any landing page.
A server action forwards the signup with your secret key, and useActionState shows the result without a page reload. The form still posts if JavaScript hasn't loaded yet.
"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>
);
}Going further with Next.js? Read How to build a Next.js waitlist with a server action.
An Astro Action with accept: "form" validates the email with Zod and forwards it. The form posts without client JavaScript, so the page must render on demand: add an adapter and keep 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>
)}Going further with Astro? Read Build a waitlist in Astro.
A createServerFn POST function receives the FormData, checks it in .validator() and forwards it from the server. The route calls it through useServerFn.
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>
);
}Going further with TanStack Start? Read Build a waitlist in TanStack Start.
The route's action forwards the post, and actionData carries the result back to the component. data() keeps Ikiform's status code on errors.
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>
);
}Going further with React Router? Read Build a waitlist in React Router.
A default form action forwards the post and returns fail() with the message on errors. use:enhance submits without a reload and keeps the typed email in the input. In SvelteKit 3, declare IKIFORM_SECRET_KEY in src/env.ts with defineEnvVars so $app/env/private can read it.
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}Going further with SvelteKit? Read Build a waitlist in SvelteKit.
A server route keeps the secret key out of the browser. Set NUXT_IKIFORM_SECRET_KEY in the environment, and Nuxt fills runtimeConfig.ikiformSecretKey from it.
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>Going further with Nuxt? Read Build a waitlist in Nuxt.
A single-page app has no server of its own, so the form posts straight to Ikiform from the browser. Never put a secret key in this code; Allowed domains protects the form instead.
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>
);
}The same browser-side post in a Vue single-file component. The form keeps its action, so it still works if the script fails to load.
<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>- The endpoint is your form's URL. Replace
frm_xxxxxxxxxxxxxxxxwith the id from the dashboard. name="email"is the field id. Keep it asemail: Ikiform uses it as the reply-to address of notification emails, and checks it against disposable email domains.- The hidden
_gotchainput is the honeypot. People never see it. Bots that fill in every input mark themselves as spam, and the server versions pass it on untouched. - The hidden
refinput records where the signup came from. Change its value per page or campaign. - With a field definition on the Fields tab, an input it doesn't list is refused with
422and anunknown_fielderror, so listrefas well asemail, as below. - A plain HTML post redirects the browser after success. Set Redirect after submit in the form's settings to your own thank-you page, or Ikiform shows its hosted one.
{
"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 server-only environment variables.The server versions pass on the browser's Origin header, because Ikiform spots free localhost tests by it and a server's own fetch sends none.
Framework guides in the docs go deeper: Next.js, Astro, SvelteKit, Nuxt, React, Vue and plain HTML. The SDK wraps the browser request as submit(), and the waitlist form template generates the four-field version.
Fields worth asking for
Email is the only field you need. If you want to sort the list before launch, add at most two more. Try the four-field version below: it runs Ikiform's validator in your browser and shows what would be stored, or the 422 you'd get back.
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
ref, which your page fills with a hidden input rather than a visible box. Paste it on the form's Fields tab and Ikiform enforces it on the server.| Field | Why you'd ask | Keep in mind |
|---|---|---|
email | The list itself | Required. Use type="email" so phones show the right keyboard |
name | A personal launch email | Optional. A first name is enough |
role | Invite founders first, or write a different email to engineers | A select keeps answers clean for filtering |
source | Learn which channel works before you spend on it | Free text is honest but messy. A hidden ref input is cleaner, see below |
ref | Count signups per page or campaign without asking | A hidden input. Optional text with max_length 40 |
To track where signups come from without asking, add a hidden input per page or per campaign. Names starting with _ are instructions to Ikiform and are never stored, so use a plain name such as ref:
<input type="hidden" name="ref" value="homepage" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />Each submission also records the page's origin and referrer, so you can see which page a signup came from even without ref. The _redirect input sends the browser to your own thank-you page; it only works for an https: URL on one of the form's Allowed domains.
true with the submission's timestamp, which records when the person agreed. The newsletter template has one.Keep bots off the list
A public form with an email field attracts bots within days. Fake signups inflate your numbers and, worse, your launch email lands in spam traps that hurt your sender reputation. Ikiform layers four defences; the first two are on with no setup.
| Defence | What it stops | Setup |
|---|---|---|
| Honeypot | Simple bots that fill in every input | The hidden _gotcha input in the form above |
| Spam scoring | Disposable addresses (+2), identical repeats (+3), links and known spam phrases. At 5 a signup is stored as spam | None |
| Rate limits | Scripts that post in a loop | On by default: Standard, 5 a minute per visitor, and 300 a minute for the whole form |
| Human check | Bots that run a real browser | Set Spam check to Ikiform. It runs Cloudflare Turnstile with no keys to manage |
Spam is stored with a normal success answer, so a bot learns nothing. It sends no email or webhook and doesn't count toward your usage. Check the Spam filter in the dashboard once a week in the first month; if a real signup lands there, mark it as not spam and its webhook goes out then.
Add your domain under Allowed domains too. Posts from other sites are then marked as spam, or refused with 403 origin_not_allowed if you choose Reject. With the human check on, the HTML form needs no change. The fetch version must ask for a token first with challenge() from @ikiform/sdk; the spam docs show the four lines.
The same person, twice
People sign up again from another tab, a second device, or a week later when they forget. Ikiform stores every accepted post and doesn't merge or reject repeats by email. Here's what happens to a repeat:
| Case | Result |
|---|---|
| Same email, different fields or more than 24 hours later | A second, normal submission |
| The same fields again within 24 hours | +3 to the spam score, below 5, so a second normal submission |
| An identical repeat from a disposable email domain | +3 and +2, which reaches 5: stored as spam |
So expect some duplicates, and remove them where the list ends up. Three places work, from least to most effort:
- In the email tool. Most email tools key contacts by address, so adding the same address twice updates one contact instead of creating two.
- On export. Remove duplicate emails from the CSV before you import it anywhere. Two ways are below.
- In your relay. Lowercase and trim the address, then insert it with a unique constraint or an upsert, so a repeat is a no-op.
Want to stop a double click from creating two rows at all? Send an idempotency key per page load with the fetch version. That stops accidental repeats; it doesn't stop a person signing up twice on purpose.
Confirming signups
Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a webhook. That's deliberate: the tool that sends your launch email should also send the confirmation, so unsubscribes and bounces are tracked in one place.
Two ways to connect Ikiform to it:
- No code. Add a webhook endpoint that points at an automation tool, then use its action for your email tool. See Zapier, Make or n8n.
- A small relay. A route handler on any Node host verifies the signature and calls your email tool's API. The example below is a Next.js route; the same code runs in a 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;
}
}verifyWebhookchecks the signature and the timestamp, so nobody else can add addresses through this URL.- Swap the
fetchfor your email tool's add-contact call. Most take an email and a list or tag, and send their own confirmation email when double opt-in is on in that tool. - Answering
204tells Ikiform the delivery succeeded. A502makes Ikiform retry, with the samewebhook-id, so a short outage in your email tool doesn't lose signups.
Add the endpoint under Webhooks in the dashboard and copy its whsec_ secret into IKIFORM_WEBHOOK_SECRET. A workspace can have 3 endpoints. The webhooks docs cover retries and the payload, and your own server has a version that stores each delivery once.
Launch day
- The page goes live with the form posting to Ikiform.
- Every week, share the page and read the new signups and their answers.
- A week before launch, export the list as CSV, remove duplicates and split it by role.
- On launch day, send one email to the list from your email tool.
Export the list
Open Submissions in the dashboard, pick the waitlist form, set the status to Received and the date range to All time, then choose Export CSV. The columns are id, created_at, form and status, then one column per field. The file is UTF-8 with a byte order mark, so accents survive Excel. One export holds up to 100,000 rows; past that, page through the REST API.
To remove duplicates in Google Sheets, import the CSV and use this formula in an empty sheet, with the email in column E:
=SORTN(A2:F, 9^9, 2, LOWER(TRIM(E2:E)), TRUE)It keeps one row per address, ignoring case and spaces, sorted by email. Or run this with Node in the folder that holds the export. It keeps the first row for each address and assumes no commas inside values, which holds for an email-only form:
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`);Send the launch email
Send it from your email tool, not from your own inbox. A personal inbox sending to hundreds of addresses at once looks like spam to mail providers, and you'll want unsubscribe handling the moment you send. Keep it short:
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- Say what is live and link straight to the sign-up or sign-in page.
- Give waiting a reward, such as a longer trial or a discount, and say it once.
- Send it from a person. Replies are the best early feedback you'll get.
- Send founders and engineers different first lines if you collected
role. That's the reason to collect it.
Pick your situation
The steps above work everywhere. These pages cover what changes for a particular launch or site builder.
- Product Hunt launch listCollect a launch list weeks before Product Hunt, tag each signup by channel, and send the launch-day email in two waves timed to the Pacific Time reset.
- Beta signup with screeningA beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.
- Mobile app waitlistA pre-launch waitlist for an iOS or Android app that asks which phone people use, sends iPhone testers to TestFlight and fills a Play closed test.
- Next.js waitlistA Next.js App Router waitlist: a server action that forwards signups with a secret key, useActionState, an optimistic thank-you and a per-visitor limit.
- Waitlist on FramerTwo ways to collect waitlist signups on Framer: Framer's form with a webhook to Ikiform, or a code component that keeps per-visitor limits and spam checks.
- Waitlist on WebflowPoint a Webflow Form Block at Ikiform, tag signups by source with a hidden field, and keep Webflow's success state with a short script for every form.
- Waitlist on CarrdCollect waitlist signups on a Carrd page: a Send to URL form on Pro Plus with UTM tracking, or an embedded HTML form with a honeypot on Pro Standard.
- Plain HTML waitlistA complete no-build waitlist page in plain HTML that works with JavaScript off, shows an inline thank-you when it loads, and falls back when it must.
- Astro waitlistAn Astro waitlist on a static landing page: an Action forwards each signup, a script calls it, and one on-demand page catches posts without JavaScript.
- TanStack Start waitlistA TanStack Start waitlist: one server route answers both a plain form post and fetch, and validateSearch carries the source and the result in the URL.
- React Router waitlistA React Router waitlist in the footer of every page: a fetcher posts to one route action, tags each signup with its page and works without JavaScript.
- SvelteKit waitlistA SvelteKit 3 waitlist: a hook keeps the first ?ref= in a cookie, a named form action forwards the signup, and returning visitors see they joined.
- Nuxt waitlistA Nuxt waitlist on a prerendered landing page: one Nitro route takes $fetch and plain form posts, checks them with Zod and redirects without JavaScript.
Check it works
Run through this before you share the page. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Fill in the form on your page and submit it. You land on your thank-you page, or Ikiform's hosted one.
- Open Submissions in the dashboard. The new submission is there with every field you sent.
- Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an
emailfield. - If you added a webhook, open the endpoint in Webhooks. The delivery shows
succeededwith your server's answer. - Post with the honeypot filled in,
_gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook. - Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.
- Sign up twice with the same address and confirm your email tool still shows one contact.
Troubleshooting
| Problem | What to do |
|---|---|
403 origin_not_allowed, or every post lands in spam | The page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed. |
429 rate_limited | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP. |
403 challenge_required | The form's spam check is set to Ikiform and a fetch post had no token. Get one with challenge(formId) from @ikiform/sdk before each submit, or turn the check off. |
| The submission is in the dashboard but no email or webhook arrived | It was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks. |
| The browser lands on Ikiform's thank-you page instead of yours | _redirect is ignored unless its domain is in Allowed domains and it is an https: URL. Add the domain, or set Redirect after submit in the form's settings instead. |
| The same email shows up twice | Expected. Ikiform doesn't deduplicate by email. Remove repeats on export, or upsert by email in your relay or email tool. |
| Signups arrive in Ikiform but not in the email tool | Open the endpoint under Webhooks and read the delivery log. A 400 means the secret is wrong; a 502 with your tool's status means the tool refused the contact. |
402 test_limit_reached | The workspace has no plan and used its 50 free localhost tests this month. Pick a plan, or use Send test in the dashboard. |
Questions
No server of your own. The form needs somewhere to post, which is what a form backend is: point the form's action at your Ikiform endpoint and it stores, filters and forwards each signup. Your landing page can stay static.
No. Ikiform emails you and your team, not the person who signs up, so there is no confirmation or double opt-in email. Send each signup by webhook to your email tool and turn on double opt-in there.
Keep the hidden _gotcha honeypot, leave the default rate limit on, add your domain under Allowed domains, and set the spam check to Ikiform for a Cloudflare Turnstile check with no keys to manage. Disposable addresses also add to the spam score.
It can be. Ikiform keeps every accepted post and doesn't merge repeats by email. Deduplicate where the list lives: your email tool, a unique column in your database, or the CSV before you import it.
Not with Ikiform alone. It has no referral links or leaderboards. You can pass a referral code in a hidden field and count it yourself, or use a waitlist product built around referrals if that's central to your launch.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.