# How to build a beta signup form that screens applicants

> A beta application form with screening questions the server enforces, and a script that scores applicants and picks each cohort from the list.

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

Ask a few screening questions next to the email, and let the form backend enforce them so every application arrives complete and comparable. Then pick each cohort from the list with a short scoring script, invite a small group, and leave everyone else waiting for the next round.

A waitlist emails everyone on launch day. A beta does the opposite: you let in twenty people, learn from them, then let in twenty more. That changes the form. You need answers you can sort by, not only an address.

## What changes for a beta

- **It's a selection.** You invite a few people at a time, so you need a way to rank applicants against each other. Closed choices, such as role and team size, sort cleanly. Free text doesn't.
- **Incomplete applications cost a slot.** If someone skips the question that tells you whether they fit, you can't pick them and you can't drop them either. Make the screening questions required, and have the server refuse posts that skip them.
- **Invites go out in rounds.** Your list needs to remember who you already invited, so the next cohort skips them. Ikiform has no tags, so that state lives in your script or sheet.
- **People who weren't picked still count.** They're your next cohort. Tell them on the thank-you line that groups go out over time, so a quiet inbox doesn't read as a no.

## Screening questions

Six questions is the ceiling. Each one should change who you invite; if you can't say how an answer would move someone up or down, cut it. Try the form below. It runs the same field definition Ikiform enforces on the server, so you can see the `422` an incomplete application gets.

Form: Beta application

- **Work email** (`email`, email, required)
- **Your role** (`role`, select, required)
- **Team size** (`team_size`, radio, required)
- **What would you use it for first?** (`use_case`, textarea, required)
- **What do you use today?** (`current_tool`, text)
- **I'm open to a 20-minute call** (`interview`, checkbox)

Paste this definition on the form's **Fields** tab. The `use_case` answer needs at least 40 characters, which filters out one-word applications.

| Field | What it decides | Why this type |
| --- | --- | --- |
| `email` | Who to invite | Required. A work email hints at the company without asking |
| `role` | Whether they feel the problem you solve | A `select` with your own buckets, so you can weight them |
| `team_size` | Whether your first version fits their setup | A `radio`: one answer, always comparable |
| `use_case` | Whether they'll use the beta this month | A `textarea` with a minimum length. The most honest signal on the form |
| `current_tool` | Who you're replacing, and what they'll compare you to | Optional free text |
| `interview` | Who you can talk to | A `checkbox`. Missing means `false`, so it never blocks an application |

> With a field definition in place, Ikiform refuses any field it doesn't know with `422` and an `unknown_field` error. If you add a hidden input such as `ref`, add it to the definition too.

## The form

Every version below shows the server's message under the field it belongs to, because a screening form fails for more reasons than a bad email. Each one keeps what the person typed when a post is refused, so nobody retypes a paragraph.

**HTML**

Native `required` and `minlength` catch most gaps before the post. The plain form works with no script and lands on your thank-you page; the script keeps people on the page and lists each field error.

`beta.html`:

```html
<form id="beta-form" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="email">Work email</label>
  <input id="email" name="email" type="email" autocomplete="email" required>

  <label for="role">Your role</label>
  <select id="role" name="role" required>
    <option value="">Choose one</option>
    <option value="founder">Founder</option>
    <option value="engineer">Engineer</option>
    <option value="product">Product manager</option>
    <option value="designer">Designer</option>
    <option value="other">Something else</option>
  </select>

  <fieldset>
    <legend>Team size</legend>
    <label><input type="radio" name="team_size" value="1" required> Only me</label>
    <label><input type="radio" name="team_size" value="2-10"> 2 to 10</label>
    <label><input type="radio" name="team_size" value="11-50"> 11 to 50</label>
    <label><input type="radio" name="team_size" value="51+"> More than 50</label>
  </fieldset>

  <label for="use_case">What would you use it for first?</label>
  <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>

  <label for="current_tool">What do you use today?</label>
  <input id="current_tool" name="current_tool" maxlength="120">

  <label><input type="checkbox" name="interview" value="true"> I'm open to a 20-minute call</label>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden>
  <ul id="beta-errors" role="alert"></ul>
  <button>Apply for the beta</button>
</form>
```

`Before </body>`:

```html
<script>
  const form = document.getElementById("beta-form");
  const errors = document.getElementById("beta-errors");
  const labels = { email: "Work email", role: "Role", team_size: "Team size", use_case: "First use", current_tool: "Current tool" };

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button");
    button.disabled = true;
    errors.replaceChildren();
    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.outerHTML = "<p role=\"status\">Thanks for applying. We invite testers in small groups and will email you either way.</p>";
        return;
      }
      const fields = Object.entries(result.error.fields ?? {});
      const lines = fields.length ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`) : [result.error.message];
      errors.replaceChildren(...lines.map((line) => Object.assign(document.createElement("li"), { textContent: line })));
    } catch {
      errors.textContent = "Couldn't reach the server. Your answers are still here; try again.";
    }
    button.disabled = false;
  });
</script>
```

**Next.js**

React resets a form after a server action runs, which would wipe a long answer on a `422`. The action returns the answers as `values`, and each input reads them back through `defaultValue`.

`app/beta/actions.ts`:

```ts
"use server";

import { headers } from "next/headers";

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

export async function apply(_previous: ApplyState, formData: FormData): Promise<ApplyState> {
  const answers = Object.fromEntries(
    [...formData].filter((entry): entry is [string, string] => typeof entry[1] === "string" && !entry[0].startsWith("$ACTION")),
  );
  const origin = (await 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 ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(answers),
  });
  const result = await response.json().catch(() => null);
  if (result?.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way." };
  return { ok: false, message: result?.error?.message ?? "Couldn't send your application. Try again.", fields: result?.error?.fields, values: answers };
}
```

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

```tsx
"use client";

import { useActionState } from "react";
import { type ApplyState, apply } from "./actions";

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

export function BetaForm() {
  const [state, action, pending] = useActionState(apply, { ok: false, message: "" } as ApplyState);
  const errors = state.fields ?? {};
  const values = state.values ?? {};

  if (state.ok) return <p role="status">{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={values.email} />
      <FieldError messages={errors.email} />
      <label htmlFor="role">Your role</label>
      <select id="role" name="role" required defaultValue={values.role}>
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="product">Product manager</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required defaultChecked={values.team_size === size} /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required defaultValue={values.use_case} />
      <FieldError messages={errors.use_case} />
      <label htmlFor="current_tool">What do you use today?</label>
      <input id="current_tool" name="current_tool" maxLength={120} defaultValue={values.current_tool} />
      <label>
        <input type="checkbox" name="interview" value="true" defaultChecked={values.interview === "true"} /> I'm open to a 20-minute call
      </label>
      <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" : "Apply for the beta"}</button>
    </form>
  );
}
```

**Astro**

With no `input` schema, an Astro Action receives the raw `FormData` and Ikiform does the validation. The handler returns field errors as data instead of throwing, so the page can place each message.

`src/actions/index.ts`:

```ts
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";

export const server = {
  apply: defineAction({
    accept: "form",
    handler: async (formData: FormData, context) => {
      const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
      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(answers),
      });
      const result = await response.json();
      if (result.ok) return { ok: true, fields: {} as Record<string, string[]>, message: "" };
      return { ok: false, fields: (result.error.fields ?? {}) as Record<string, string[]>, message: String(result.error.message) };
    },
  }),
};
```

`src/pages/beta.astro`:

```astro
---
import { actions } from "astro:actions";

export const prerender = false;

const result = Astro.getActionResult(actions.apply)?.data;
const errors = result?.fields ?? {};
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
---

{result?.ok ? (
  <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
) : (
  <form method="POST" action={actions.apply}>
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {errors.email && <p role="alert">{errors.email[0]}</p>}
    <label for="role">Your role</label>
    <select id="role" name="role" required>
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="product">Product manager</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <fieldset>
      <legend>Team size</legend>
      {sizes.map(([size, label]) => <label><input type="radio" name="team_size" value={size} required /> {label}</label>)}
    </fieldset>
    <label for="use_case">What would you use it for first?</label>
    <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>
    {errors.use_case && <p role="alert">{errors.use_case[0]}</p>}
    <label for="current_tool">What do you use today?</label>
    <input id="current_tool" name="current_tool" maxlength="120" />
    <label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {result && !result.ok && !Object.keys(errors).length && <p role="alert">{result.message}</p>}
    <button>Apply for the beta</button>
  </form>
)}
```

**TanStack Start**

The server function forwards every text entry. The form submits through `onSubmit`, so its inputs keep their values when the server answers with errors.

`src/routes/beta.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 applyForBeta = createServerFn({ method: "POST" })
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) throw new Error("Expected form data");
    return Object.fromEntries([...data].filter((entry): entry is [string, string] => typeof entry[1] === "string"));
  })
  .handler(async ({ data: answers }) => {
    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(answers),
    });
    const result = await response.json();
    if (result.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} };
    return { ok: false, message: String(result.error.message), fields: (result.error.fields ?? {}) as Record<string, string[]> };
  });

export const Route = createFileRoute("/beta")({ component: Beta });

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

function Beta() {
  const applyFn = useServerFn(applyForBeta);
  const [state, setState] = useState<{ ok: boolean; message: string; fields: Record<string, string[]> } | null>(null);
  const [pending, setPending] = useState(false);
  const errors = state?.fields ?? {};

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    try {
      setState(await applyFn({ data: new FormData(event.currentTarget) }));
    } catch {
      setState({ ok: false, message: "Couldn't send your application. Try again.", fields: {} });
    } finally {
      setPending(false);
    }
  }

  if (state?.ok) return <p role="status">{state.message}</p>;

  return (
    <form method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <FieldError messages={errors.email} />
      <label htmlFor="role">Your role</label>
      <select id="role" name="role" required>
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="product">Product manager</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
      <FieldError messages={errors.use_case} />
      <label htmlFor="current_tool">What do you use today?</label>
      <input id="current_tool" name="current_tool" maxLength={120} />
      <label>
        <input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call
      </label>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state && !Object.keys(errors).length ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
    </form>
  );
}
```

**React Router**

The route `action` forwards the post and keeps Ikiform's status with `data()`. `<Form>` submits without a reload, so the answers stay in place.

`app/routes/beta.tsx`:

```tsx
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/beta";

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
  const origin = 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 ${process.env.IKIFORM_SECRET_KEY}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(answers),
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} };
  return data(
    { ok: false, message: String(result.error.message), fields: (result.error.fields ?? {}) as Record<string, string[]> },
    { status: response.status },
  );
}

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

export default function Beta({ actionData }: Route.ComponentProps) {
  const pending = useNavigation().state === "submitting";
  const errors: Record<string, string[]> = actionData?.fields ?? {};

  if (actionData?.ok) return <p role="status">{actionData.message}</p>;

  return (
    <Form method="post">
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <FieldError messages={errors.email} />
      <label htmlFor="role">Your role</label>
      <select id="role" name="role" required>
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="product">Product manager</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
      <FieldError messages={errors.use_case} />
      <label htmlFor="current_tool">What do you use today?</label>
      <input id="current_tool" name="current_tool" maxLength={120} />
      <label>
        <input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call
      </label>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData && !Object.keys(errors).length ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Apply for the beta"}</button>
    </Form>
  );
}
```

**SvelteKit**

`fail()` returns the field errors to `form`. `use:enhance` only resets the form after a success, so a refused application keeps its answers. Declare `IKIFORM_SECRET_KEY` in `src/env.ts` with `defineEnvVars`, as in the [SvelteKit waitlist](https://ikiform.com/guides/waitlist/sveltekit.md#env), so `$app/env/private` can read it, and forward the browser's `Origin` so local tests count as free `localhost` posts.

`src/routes/beta/+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 formData = await request.formData();
    const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
    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(answers),
    });
    const result = await response.json();
    if (!result.ok) {
      return fail(response.status, {
        message: String(result.error.message),
        fields: (result.error.fields ?? {}) as Record<string, string[]>,
      });
    }
    return { applied: true };
  },
} satisfies Actions;
```

`src/routes/beta/+page.svelte`:

```svelte
<script lang="ts">
  import { enhance } from "$app/forms";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
</script>

{#if form?.applied}
  <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
{:else}
  <form method="POST" use:enhance>
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    {#if form?.fields?.email}<p role="alert">{form.fields.email[0]}</p>{/if}
    <label for="role">Your role</label>
    <select id="role" name="role" required>
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="product">Product manager</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <fieldset>
      <legend>Team size</legend>
      {#each sizes as [size, label]}
        <label><input type="radio" name="team_size" value={size} required /> {label}</label>
      {/each}
    </fieldset>
    <label for="use_case">What would you use it for first?</label>
    <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required></textarea>
    {#if form?.fields?.use_case}<p role="alert">{form.fields.use_case[0]}</p>{/if}
    <label for="current_tool">What do you use today?</label>
    <input id="current_tool" name="current_tool" maxlength="120" />
    <label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if form?.message && !form?.fields?.email && !form?.fields?.use_case}<p role="alert">{form.message}</p>{/if}
    <button>Apply for the beta</button>
  </form>
{/if}
```

**Nuxt**

The server route reads the `FormData` with `readFormData` and passes Ikiform's status through. `ignoreResponseError` makes `$fetch` return the error body instead of throwing.

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

```ts
export default defineEventHandler(async (event) => {
  const formData = await readFormData(event);
  const answers = Object.fromEntries([...formData].filter((entry) => typeof entry[1] === "string"));
  const { ikiformSecretKey } = 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 ${ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(answers),
  });
  const result = await response.json();
  setResponseStatus(event, response.status);
  return result.ok
    ? { ok: true, message: "Thanks for applying. We invite testers in small groups and will email you either way.", fields: {} }
    : { ok: false, message: String(result.error.message), fields: result.error.fields ?? {} };
});
```

`app/components/BetaForm.vue`:

```vue
<script setup lang="ts">
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
const state = ref<{ ok: boolean; message: string; fields: Record<string, string[]> } | null>(null);
const pending = ref(false);

async function apply(event: SubmitEvent) {
  pending.value = true;
  try {
    state.value = await $fetch("/api/beta", {
      method: "POST",
      body: new FormData(event.target as HTMLFormElement),
      ignoreResponseError: true,
    });
  } catch {
    state.value = { ok: false, message: "Couldn't send your application. Try again.", fields: {} };
  }
  pending.value = false;
}
</script>

<template>
  <p v-if="state?.ok" role="status">{{ state.message }}</p>
  <form v-else method="post" @submit.prevent="apply">
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <p v-if="state?.fields.email" role="alert">{{ state.fields.email[0] }}</p>
    <label for="role">Your role</label>
    <select id="role" name="role" required>
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="product">Product manager</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <fieldset>
      <legend>Team size</legend>
      <label v-for="[size, label] in sizes" :key="size">
        <input type="radio" name="team_size" :value="size" required /> {{ label }}
      </label>
    </fieldset>
    <label for="use_case">What would you use it for first?</label>
    <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required />
    <p v-if="state?.fields.use_case" role="alert">{{ state.fields.use_case[0] }}</p>
    <label for="current_tool">What do you use today?</label>
    <input id="current_tool" name="current_tool" maxlength="120" />
    <label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button :disabled="pending">{{ pending ? "Sending" : "Apply for the beta" }}</button>
  </form>
</template>
```

**React**

A single-page app posts straight to Ikiform from the browser, so there is no secret key in this code. Keep **Allowed domains** set to your site.

`src/BetaForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const SIZES = [
  ["1", "Only me"],
  ["2-10", "2 to 10"],
  ["11-50", "11 to 50"],
  ["51+", "More than 50"],
];

function FieldError({ messages }: { messages?: string[] }) {
  return messages?.[0] ? <p role="alert">{messages[0]}</p> : null;
}

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

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    setMessage("");
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) return setStatus("applied");
      setErrors(result.error.fields ?? {});
      setMessage(result.error.message);
    } catch {
      setMessage("Couldn't reach the server. Try again.");
    }
    setStatus("idle");
  }

  if (status === "applied") return <p role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <FieldError messages={errors.email} />
      <label htmlFor="role">Your role</label>
      <select id="role" name="role" required>
        <option value="">Choose one</option>
        <option value="founder">Founder</option>
        <option value="engineer">Engineer</option>
        <option value="product">Product manager</option>
        <option value="designer">Designer</option>
        <option value="other">Something else</option>
      </select>
      <FieldError messages={errors.role} />
      <fieldset>
        <legend>Team size</legend>
        {SIZES.map(([size, label]) => (
          <label key={size}>
            <input type="radio" name="team_size" value={size} required /> {label}
          </label>
        ))}
      </fieldset>
      <FieldError messages={errors.team_size} />
      <label htmlFor="use_case">What would you use it for first?</label>
      <textarea id="use_case" name="use_case" rows={4} minLength={40} maxLength={1000} required />
      <FieldError messages={errors.use_case} />
      <label htmlFor="current_tool">What do you use today?</label>
      <input id="current_tool" name="current_tool" maxLength={120} />
      <label>
        <input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call
      </label>
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {message && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Apply for the beta"}</button>
    </form>
  );
}
```

**Vue**

The same browser post in a Vue component. The form keeps its `action`, so it still posts if the script fails.

`src/components/BetaForm.vue`:

```vue
<script setup lang="ts">
import { ref } from "vue";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sizes = [["1", "Only me"], ["2-10", "2 to 10"], ["11-50", "11 to 50"], ["51+", "More than 50"]];
const status = ref<"idle" | "sending" | "applied">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");

async function apply(event: SubmitEvent) {
  status.value = "sending";
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    if (result.ok) {
      status.value = "applied";
      return;
    }
    errors.value = result.error.fields ?? {};
    message.value = result.error.message;
  } catch {
    message.value = "Couldn't reach the server. Try again.";
  }
  status.value = "idle";
}
</script>

<template>
  <p v-if="status === 'applied'" role="status">Thanks for applying. We invite testers in small groups and will email you either way.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="apply">
    <label for="email">Work email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email" role="alert">{{ errors.email[0] }}</p>
    <label for="role">Your role</label>
    <select id="role" name="role" required>
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="product">Product manager</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <fieldset>
      <legend>Team size</legend>
      <label v-for="[size, label] in sizes" :key="size">
        <input type="radio" name="team_size" :value="size" required /> {{ label }}
      </label>
    </fieldset>
    <label for="use_case">What would you use it for first?</label>
    <textarea id="use_case" name="use_case" rows="4" minlength="40" maxlength="1000" required />
    <p v-if="errors.use_case" role="alert">{{ errors.use_case[0] }}</p>
    <label for="current_tool">What do you use today?</label>
    <input id="current_tool" name="current_tool" maxlength="120" />
    <label><input type="checkbox" name="interview" value="true" /> I'm open to a 20-minute call</label>
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="message && !Object.keys(errors).length" role="alert">{{ message }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Apply for the beta" }}</button>
  </form>
</template>
```

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

## Pick a cohort

Diagram: How applications become beta cohorts

1. Applications arrive complete, because Ikiform refuses posts that skip a required question.
2. A script reads them through the API and scores each one on role, team size, the length of the use case and the call checkbox.
3. The top 25 become the first cohort and get an invite from your email tool.
4. Two weeks later, run the script again with the invited emails, so they're skipped, and pick the next group.
5. Everyone below the cut stays on the list and hears from you when their round comes.

Scores are a starting order, not a verdict. Read the use case of everyone near the cut line yourself.

The script reads every received application with the [REST API](https://ikiform.com/docs/api.md#list-submissions), keeps the newest one per email, scores it and writes the top group to `cohort.csv`. Pass the emails you've already invited as arguments so the next round skips them. It needs a secret key with the `submissions:read` scope.

`cohort.mjs`:

```js
import { writeFileSync } from "node:fs";
import { createClient } from "@ikiform/sdk";

const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY });
const COHORT_SIZE = 25;
const invited = new Set(process.argv.slice(2).map((email) => email.toLowerCase()));

const ROLE = { founder: 3, engineer: 3, product: 2, designer: 1, other: 0 };
const SIZE = { "2-10": 3, "11-50": 2, "1": 1, "51+": 1 };

const people = new Map();
for await (const submission of ikiform.submissions.iterate({ formId: "frm_xxxxxxxxxxxxxxxx", status: "received", limit: 100 })) {
  const answers = submission.fields;
  const email = String(answers.email ?? "").trim().toLowerCase();
  if (!email || invited.has(email) || people.has(email)) continue;
  const words = String(answers.use_case ?? "").split(/\s+/).length;
  const score =
    (ROLE[answers.role] ?? 0) +
    (SIZE[answers.team_size] ?? 0) +
    Math.min(3, Math.floor(words / 25)) +
    (answers.interview === true ? 2 : 0) +
    (answers.current_tool ? 1 : 0);
  people.set(email, { email, score, role: answers.role, team_size: answers.team_size });
}

const ranked = [...people.values()].sort((a, b) => b.score - a.score);
const cohort = ranked.slice(0, COHORT_SIZE);
const quote = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const rows = cohort.map((person) => [person.email, person.score, person.role, person.team_size].map(quote).join(","));
writeFileSync("cohort.csv", ["email,score,role,team_size", ...rows].join("\n"));
console.log(`${ranked.length} applicants, ${cohort.length} in this cohort, lowest score ${cohort.at(-1)?.score}`);
```

- Change `ROLE` and `SIZE` to match who your first version is for. A team tool might score `2-10` highest; a solo tool, `1`.
- The use case earns a point per 25 words, up to 3. Length isn't quality, but a person who writes three sentences usually shows up.
- Quoting every value keeps commas in answers from breaking the CSV.
- Store the invited list somewhere you'll find it, such as an `invited.txt` you pass with `$(cat invited.txt)`.

### Send the invite

Import `cohort.csv` into your email tool and send the invite from there. 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). Keep the first invite short: what they get, the one thing you want them to try, and how to reach you. Each notification email Ikiform sends you has a **Reply** that goes to the applicant, which suits a quick personal note to the people near the cut line.

## Check it works

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. Paste the field definition on the form's **Fields** tab and save it.
3. Submit an application with a one-line use case. The form shows the minimum-length message under that field, and nothing is stored.
4. Submit a complete application. It appears under **Submissions** with every answer, and `interview` reads `true` or `false`.
5. 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.
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. Run the scoring script with a secret key and check `cohort.csv` lists your test application with its score.

## Gotchas

| Problem | What to do |
| --- | --- |
| `422 validation_failed` | The post doesn't match the form's field definition. `error.fields` names each field and the reason. Match the input `name` to the field id. |
| `422` with Unknown field for `ref`, `utm_source` or another hidden input | The field definition doesn't list it. Add it as an optional `text` field, or rename it with a leading `_` so Ikiform drops it. |
| A long answer disappears after an error in Next.js | React resets the form after a server action. Return the answers from the action and set `defaultValue` on each input, as the Next.js sample does. |
| The scoring script throws `401 unauthorized` or `403 forbidden` | A `401` means the key is missing or revoked; a `403` means it is publishable or lacks the scope. Use a secret key (`ik_sk_`) with the `submissions:read` scope. |
| 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. |

## Questions

### How many questions should a beta signup form have?

Three to six. Each question should change who you invite. Required closed choices, such as role and team size, sort well; one required open question with a minimum length tells you who will use the beta.

### Can Ikiform reject applications that skip a screening question?

Yes. Define the fields on the form's Fields tab and mark them required. A post that misses one is refused with 422 validation_failed, and the error names each field, so your form can show the message in place.

### Can I tag applicants as invited in Ikiform?

No. Ikiform has no tags or custom statuses. Keep the invited list where you send invites, or in a file you pass to the scoring script, as shown above.

### Does Ikiform remove duplicate submissions?

No. Every accepted post is stored. An identical repeat within 24 hours adds to the spam score but isn't spam on its own, so deduplicate by email in your list, database or CSV export.

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

- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
- [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.
- [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.
- [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.
