# Waitlist form: HTML, React and Next.js code

> A waitlist signup form for a product launch. Collect emails with an optional role and referral source, then confirm and deduplicate them in the tool you send them to.

Updated 2026-10-24 · https://ikiform.com/forms/waitlist

## Copy the code

Every version below is generated from the same field definition. Replace `frm_xxxxxxxxxxxxxxxx` with your form id, or enter it in the **Form ID** box and the code updates.

**HTML** (`waitlist.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 />
  <label for="name">Name</label>
  <input id="name" name="name" type="text" maxlength="120" />
  <label for="role">What do you do?</label>
  <select id="role" name="role">
    <option value="">Choose one</option>
    <option value="founder">Founder</option>
    <option value="engineer">Engineer</option>
    <option value="designer">Designer</option>
    <option value="other">Something else</option>
  </select>
  <label for="source">How did you hear about us?</label>
  <input id="source" name="source" type="text" maxlength="200" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Join the waitlist</button>
</form>
```

**React** (`WaitlistForm.tsx`)

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

type Status = "idle" | "sending" | "sent" | "error";

export function WaitlistForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    const result = await submit(FORM_ID, new FormData(event.currentTarget));
    if (result.ok) return setStatus("sent");
    setErrors(result.error.fields ?? {});
    setMessage(result.error.message);
    setStatus("error");
  }

  if (status === "sent") return <p>You're on the list. We'll email you at launch.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" placeholder="you@company.com" autoComplete="email" required />
      {errors.email ? <p>{errors.email[0]}</p> : null}
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} />
      {errors.name ? <p>{errors.name[0]}</p> : null}
      <label htmlFor="role">What do you do?</label>
      <select id="role" name="role">
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      {errors.role ? <p>{errors.role[0]}</p> : null}
      <label htmlFor="source">How did you hear about us?</label>
      <input id="source" name="source" type="text" maxLength={200} />
      {errors.source ? <p>{errors.source[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status === "error" && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Join the waitlist"}</button>
    </form>
  );
}
```

**JavaScript** (`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",
  name: "Name",
  role: "What do you do?",
  source: "How did you hear about us?",
};

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

- **HTML** needs no JavaScript. After a successful post the browser is redirected to the form's redirect URL, or to Ikiform's hosted thank-you page.
- **React** uses the SDK: `npm install @ikiform/sdk`. Field errors from the server appear under each field.
- **JavaScript** enhances the HTML form with `fetch`, so the visitor stays on the page.
- The hidden `_gotcha` input is the honeypot. Leave it empty and hidden.

> If you set the form's spam check to **Ikiform**, the HTML form keeps working with no change and the server action skips the check because it sends a secret key. The React and JavaScript versions must get a token before each submit: call `challenge(FORM_ID)` from `@ikiform/sdk` and pass it to `submit` as `turnstileToken`, or add it to the `fetch` body as `cf-turnstile-response`. Without it the post is refused with `403 challenge_required`. See [the Next.js guide](https://ikiform.com/docs/guides/nextjs.md#client-human-check) and [Spam protection](https://ikiform.com/docs/spam.md#human-check).

## The fields

Paste this definition on the form's **Fields** tab in the dashboard. Ikiform then checks every post against it on the server, refuses bad data with `422 validation_failed`, refuses any field that isn't listed, and stores typed values.

| Field id | Type | Required | Accepts |
| --- | --- | --- | --- |
| `email` | `email` | Yes | One valid address |
| `name` | `text` | No | Up to 120 characters |
| `role` | `select` | No | One of: `founder`, `engineer`, `designer`, `other` |
| `source` | `text` | No | Up to 200 characters |

`Field definition`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    {
      "id": "email",
      "type": "email",
      "label": "Email",
      "required": true,
      "placeholder": "you@company.com"
    },
    {
      "id": "name",
      "type": "text",
      "label": "Name",
      "validation": {
        "max_length": 120
      }
    },
    {
      "id": "role",
      "type": "select",
      "label": "What do you do?",
      "options": [
        {
          "value": "founder",
          "label": "Founder"
        },
        {
          "value": "engineer",
          "label": "Engineer"
        },
        {
          "value": "designer",
          "label": "Designer"
        },
        {
          "value": "other",
          "label": "Something else"
        }
      ]
    },
    {
      "id": "source",
      "type": "text",
      "label": "How did you hear about us?",
      "validation": {
        "max_length": 200
      }
    }
  ],
  "settings": {
    "submit_label": "Join the waitlist",
    "success_message": "You're on the list. We'll email you at launch."
  }
}
```

## Next.js server action

To keep the form working without JavaScript in a Next.js app, post from a server action with a secret key. Create the key under **API keys** with the `submissions:write` scope and keep it out of the browser.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`app/waitlist/actions.ts`:

```ts
"use server";

export interface WaitlistFormState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
  values?: Record<string, string[]>;
}

export async function sendWaitlistForm(_previous: WaitlistFormState, formData: FormData): Promise<WaitlistFormState> {
  const body = new URLSearchParams();
  for (const [name, value] of formData) {
    if (typeof value === "string" && !name.startsWith("$ACTION")) body.append(name, value);
  }
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
    },
    body,
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "You're on the list. We'll email you at launch." };
  const values = Object.fromEntries([...new Set(body.keys())].map((name) => [name, body.getAll(name)]));
  return { ok: false, message: result.error.message, fields: result.error.fields, values };
}
```

`app/waitlist/form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type WaitlistFormState, sendWaitlistForm } from "./actions";

const initial: WaitlistFormState = { ok: false, message: "" };

export function WaitlistForm() {
  const [state, action, pending] = useActionState(sendWaitlistForm, initial);

  if (state.ok) return <p>{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" placeholder="you@company.com" autoComplete="email" required defaultValue={state.values?.email?.[0]} />
      {state.fields?.email ? <p>{state.fields.email[0]}</p> : null}
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} defaultValue={state.values?.name?.[0]} />
      {state.fields?.name ? <p>{state.fields.name[0]}</p> : null}
      <label htmlFor="role">What do you do?</label>
      <select id="role" name="role" defaultValue={state.values?.role?.[0]}>
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      {state.fields?.role ? <p>{state.fields.role[0]}</p> : null}
      <label htmlFor="source">How did you hear about us?</label>
      <input id="source" name="source" type="text" maxLength={200} defaultValue={state.values?.source?.[0]} />
      {state.fields?.source ? <p>{state.fields.source[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Join the waitlist"}</button>
    </form>
  );
}
```

> A server action posts from your server's IP, so the per-visitor rate limit counts every visitor together. Use the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for forms you send through a server.

## Set it up

1. Create a form in the [dashboard](https://ikiform.com/dashboard) and copy its id.
2. Paste the field definition on the **Fields** tab.
3. Add your site under **Allowed domains**, plus `localhost` while you develop.
4. Set the rate limit to **Standard, 5 a minute per visitor** if visitors post from the browser. Launch-day traffic is bursty but each person signs up once. Standard allows a retry without letting a script fill the list. With the server action, every post comes from your server's IP, so choose Relaxed or Custom instead.
5. Paste the code, send a test and check it arrives in the dashboard.

## Confirming signups

Ikiform doesn't email the person who signs up. Notification emails go to your workspace, not to the subscriber, so there is no built-in confirmation or double opt-in email.

If you want double opt-in, run it where the list lives:

1. Add a webhook endpoint in Ikiform that points at your automation or server.
2. On each `submission.created` event, add the address to your email tool as unconfirmed and send the confirmation from there.
3. Mark the person confirmed when they click the link. Most email tools do this step for you.

Each delivery is signed, so check the signature before you trust it, and use the `webhook-id` header to ignore retries of a delivery you already handled.

## Duplicate signups

People sign up twice, from two tabs or a week apart. Ikiform doesn't merge or reject repeats by email; it scores exact repeats as a spam signal:

| Case | What happens |
| --- | --- |
| The same email posted again within 24 hours, with identical fields | +3 to the spam score, below the threshold of 5, so it is stored as a second submission |
| A repeat from a disposable email domain | +3 and +2, which reaches 5 and is stored as spam |

Deduplicate by `email` where you store the list. A spreadsheet or database with a unique email column, or an upsert in your webhook handler, turns a repeat into a no-op. In JavaScript, send an [idempotency key](https://ikiform.com/docs/frameworks.md#idempotency) per page load so a double click never creates two submissions.

## On launch day

- The form-wide limit is 300 a minute across every visitor by default. Raise it with the Custom rate limit if you expect more.
- If one office or event shares an IP, Standard's per-visitor limit counts them together. Relaxed allows 20 a minute.
- Export the whole list as CSV from the dashboard when you're ready to send invites. See [Export](https://ikiform.com/docs/guides/export.md).

## Where to send it

Every submission can go to a signed [webhook](https://ikiform.com/docs/webhooks.md). These guides cover the tools that suit this form, with the relay code:

- [Send form submissions to Google Sheets](https://ikiform.com/blog/form-submissions-to-google-sheets.md)
- [Send form submissions to Airtable](https://ikiform.com/blog/form-submissions-to-airtable.md)
- [Send form submissions to n8n](https://ikiform.com/blog/form-submissions-to-n8n.md)
- [Send form submissions to Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)
- [Send form submissions to Make](https://ikiform.com/blog/form-submissions-to-make.md)
- [Receive form submissions on your own server](https://ikiform.com/blog/form-submissions-to-your-own-server.md)

## Questions

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

No. Ikiform sends notification emails to your workspace only. To confirm signups, forward each submission by webhook to your email tool or server and send the confirmation from there.

### Will the same email be stored twice?

It can be. An identical repeat within 24 hours adds to the spam score but isn't spam on its own. Deduplicate by email in the sheet, database or email tool you send signups to.

### Can I limit the waitlist to work emails?

Partly. The email field's allowed_domains rule accepts only the domains you list, which suits an internal or partner waitlist. There is no built-in rule that blocks every free email provider.
