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

Published 2026-10-25 · https://ikiform.com/guides/waitlist

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.

Diagram: How a waitlist signup travels from your landing page to your email tool

1. A visitor enters their email on your landing page, and the form posts to your Ikiform endpoint.
2. Ikiform checks the honeypot, the spam score and the rate limit, then stores the signup.
3. The signup shows up in the dashboard, where you can search it and export the list as CSV.
4. Ikiform emails you and your team. It never emails the person who signed up.
5. Ikiform sends a signed submission.created webhook.
6. A relay or an automation tool adds the address to your email tool, which sends any confirmation and, later, the launch email.

The email tool is optional on day one. You can collect for weeks and import a CSV later; the webhook keeps both in step from the start.

## 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](https://ikiform.com/alternatives/typeform) or [Tally](https://ikiform.com/alternatives/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.

**HTML**

Paste the form where the signup should appear. It posts the plain way and redirects, so it works with no JavaScript at all.

`index.html`:

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

`waitlist.js`:

```js
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](https://ikiform.com/guides/waitlist/html.md).

**Next.js**

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.

`app/waitlist/actions.ts`:

```ts
"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,
  };
}
```

`app/waitlist/waitlist-form.tsx`:

```tsx
"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](https://ikiform.com/guides/waitlist/nextjs.md).

**Astro**

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

`src/actions/index.ts`:

```ts
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 };
    },
  }),
};
```

`src/pages/index.astro`:

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

**TanStack Start**

A `createServerFn` POST function receives the `FormData`, checks it in `.validator()` and forwards it from the server. The route calls it through `useServerFn`.

`src/routes/index.tsx`:

```tsx
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](https://ikiform.com/guides/waitlist/tanstack-start.md).

**React Router**

The route's `action` forwards the post, and `actionData` carries the result back to the component. `data()` keeps Ikiform's status code on errors.

`app/routes/home.tsx`:

```tsx
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](https://ikiform.com/guides/waitlist/react-router.md).

**SvelteKit**

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.

`src/routes/+page.server.ts`:

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

`src/routes/+page.svelte`:

```svelte
<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](https://ikiform.com/guides/waitlist/sveltekit.md).

**Nuxt**

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.

`server/api/waitlist.post.ts`:

```ts
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 };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`app/components/WaitlistForm.vue`:

```vue
<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](https://ikiform.com/guides/waitlist/nuxt.md).

**React**

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.

`src/WaitlistForm.tsx`:

```tsx
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>
  );
}
```

**Vue**

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.

`src/components/WaitlistForm.vue`:

```vue
<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_xxxxxxxxxxxxxxxx` with the id from the dashboard.
- `name="email"` is the field id. Keep it as `email`: Ikiform uses it as the reply-to address of notification emails, and checks it against disposable email domains.
- The hidden `_gotcha` input 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 `ref` input 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 `422` and an `unknown_field` error, so list `ref` as well as `email`, 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.

`Fields`:

```json
{
  "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 } }
  ]
}
```

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `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](https://ikiform.com/docs/guides/nextjs.md), [Astro](https://ikiform.com/docs/guides/astro.md), [SvelteKit](https://ikiform.com/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/docs/guides/nuxt.md), [React](https://ikiform.com/docs/guides/react.md), [Vue](https://ikiform.com/docs/guides/vue.md) and [plain HTML](https://ikiform.com/docs/html.md). The [SDK](https://ikiform.com/docs/sdk.md) wraps the browser request as `submit()`, and the [waitlist form template](https://ikiform.com/forms/waitlist) 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.

Form: Waitlist

- **Email** (`email`, email, required)
- **Name** (`name`, text)
- **What do you do?** (`role`, select)
- **How did you hear about us?** (`source`, text)
- **Source** (`ref`, text)

The [waitlist template](https://ikiform.com/forms/waitlist)'s fields plus `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`:

`Hidden inputs`:

```html
<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**.

> If you'll send anything beyond launch news, such as a newsletter, add a required consent checkbox with the exact promise as its label. Ikiform stores `true` with the submission's timestamp, which records when the person agreed. The [newsletter template](https://ikiform.com/forms/newsletter-signup) 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](https://ikiform.com/docs/spam.md#human-check) show the four lines.

> Launch posts arrive in bursts. If you post the page somewhere big, the form-wide limit of 300 a minute can bite before any single visitor's limit does. Raise it with a Custom rate limit the day before. A whole office or conference shares one IP, so Relaxed, 20 a minute per visitor suits those days.

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

1. **In the email tool.** Most email tools key contacts by address, so adding the same address twice updates one contact instead of creating two.
2. **On export.** Remove duplicate emails from the CSV before you import it anywhere. Two ways are below.
3. **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](https://ikiform.com/docs/frameworks.md#idempotency) 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](https://ikiform.com/docs/webhooks.md). 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](https://ikiform.com/blog/form-submissions-to-zapier.md), [Make](https://ikiform.com/blog/form-submissions-to-make.md) or [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md).
- **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.

`app/api/waitlist/route.ts`:

```ts
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;
  }
}
```

- `verifyWebhook` checks the signature and the timestamp, so nobody else can add addresses through this URL.
- Swap the `fetch` for 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 `204` tells Ikiform the delivery succeeded. A `502` makes Ikiform retry, with the same `webhook-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](https://ikiform.com/docs/webhooks.md) cover retries and the payload, and [your own server](https://ikiform.com/blog/form-submissions-to-your-own-server.md) has a version that stores each delivery once.

## Launch day

Diagram: A waitlist from the first signup to launch day

1. The page goes live with the form posting to Ikiform.
2. Every week, share the page and read the new signups and their answers.
3. A week before launch, export the list as CSV, remove duplicates and split it by role.
4. On launch day, send one email to the list from your email tool.

Most of the work is the export a week out. The launch email itself is short.

### 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](https://ikiform.com/docs/guides/export.md#api).

To remove duplicates in Google Sheets, import the CSV and use this formula in an empty sheet, with the email in column E:

`Google Sheets`:

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

`dedupe.mjs`:

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

`Launch email`:

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

> If you collected for months without confirming addresses, send to a small slice first and watch the bounce rate. A list full of typos and dead addresses hurts the domain you'll send product email from.

## Pick your situation

The steps above work everywhere. These pages cover what changes for a particular launch or site builder.

- [Product Hunt launch list](https://ikiform.com/guides/waitlist/product-hunt.md): Collect 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 screening](https://ikiform.com/guides/waitlist/beta-signup.md): A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.
- [Mobile app waitlist](https://ikiform.com/guides/waitlist/mobile-app.md): A 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 waitlist](https://ikiform.com/guides/waitlist/nextjs.md): A 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 Framer](https://ikiform.com/guides/waitlist/framer.md): Two 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 Webflow](https://ikiform.com/guides/waitlist/webflow.md): Point 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 Carrd](https://ikiform.com/guides/waitlist/carrd.md): Collect 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 waitlist](https://ikiform.com/guides/waitlist/html.md): A 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 waitlist](https://ikiform.com/guides/waitlist/astro.md): An 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 waitlist](https://ikiform.com/guides/waitlist/tanstack-start.md): A 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 waitlist](https://ikiform.com/guides/waitlist/react-router.md): A 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 waitlist](https://ikiform.com/guides/waitlist/sveltekit.md): A 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 waitlist](https://ikiform.com/guides/waitlist/nuxt.md): A 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](https://ikiform.com/pricing).

1. Under the form's **Allowed domains**, add your site's domain, plus `localhost` while you develop. Leave it empty to accept posts from anywhere.
2. Fill in the form on your page and submit it. You land on your thank-you page, or Ikiform's hosted one.
3. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
4. 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 `email` field.
5. If you added a webhook, open the endpoint in **Webhooks**. The delivery shows `succeeded` with your server's answer.
6. 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.
7. Nothing arrived? Switch the dashboard's status filter to **Spam**. Spam gets the same success answer as a real submission.
8. 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

### Do I need a backend to create a waitlist page?

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.

### Does Ikiform send a confirmation email to people who join the waitlist?

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.

### How do I stop fake signups on a waitlist form?

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.

### Will the same email be stored twice?

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.

### Can I add a referral program or a place in line?

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.

### Is Ikiform free?

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.

## More guides

- [Product Hunt launch list](https://ikiform.com/guides/waitlist/product-hunt.md): Collect 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 screening](https://ikiform.com/guides/waitlist/beta-signup.md): A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.
- [Mobile app waitlist](https://ikiform.com/guides/waitlist/mobile-app.md): A 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 waitlist](https://ikiform.com/guides/waitlist/nextjs.md): A 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 Framer](https://ikiform.com/guides/waitlist/framer.md): Two 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 Webflow](https://ikiform.com/guides/waitlist/webflow.md): Point 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 Carrd](https://ikiform.com/guides/waitlist/carrd.md): Collect 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 waitlist](https://ikiform.com/guides/waitlist/html.md): A 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 waitlist](https://ikiform.com/guides/waitlist/astro.md): An 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 waitlist](https://ikiform.com/guides/waitlist/tanstack-start.md): A 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 waitlist](https://ikiform.com/guides/waitlist/react-router.md): A 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 waitlist](https://ikiform.com/guides/waitlist/sveltekit.md): A 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 waitlist](https://ikiform.com/guides/waitlist/nuxt.md): A Nuxt waitlist on a prerendered landing page: one Nitro route takes $fetch and plain form posts, checks them with Zod and redirects without JavaScript.
- [Contact form on a static site](https://ikiform.com/guides/contact-form-static-site.md): Add a working contact form to a static site with no server: where the POST goes, spam, replies by email, and code for every host and framework.
- [Send a form to your email](https://ikiform.com/guides/send-form-to-email.md): Get every form submission in your inbox without a mail server: why mailto: and PHP mail() fail, and a setup that filters spam and lets you reply.
- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.
