Waitlist for a new SaaS7 min read
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.
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.
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
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 |
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.
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.
<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><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>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.
"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 };
}"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>
);
}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.
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) };
},
}),
};---
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>
)}The server function forwards every text entry. The form submits through onSubmit, so its inputs keep their values when the server answers with errors.
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>
);
}The route action forwards the post and keeps Ikiform's status with data(). <Form> submits without a reload, so the answers stay in place.
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>
);
}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, so $app/env/private can read it, and forward the browser's Origin so local tests count as free localhost posts.
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;<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}The server route reads the FormData with readFormData and passes Ikiform's status through. ignoreResponseError makes $fetch return the error body instead of throwing.
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 ?? {} };
});<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>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.
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>
);
}The same browser post in a Vue component. The form keeps its action, so it still posts if the script fails.
<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>IKIFORM_SECRET_KEY in server-only environment variables.Pick a cohort
- Applications arrive complete, because Ikiform refuses posts that skip a required question.
- 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.
- The top 25 become the first cohort and get an invite from your email tool.
- Two weeks later, run the script again with the invited emails, so they're skipped, and pick the next group.
- Everyone below the cut stays on the list and hears from you when their round comes.
The script reads every received application with the REST API, 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.
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
ROLEandSIZEto match who your first version is for. A team tool might score2-10highest; 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.txtyou 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. 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
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Paste the field definition on the form's Fields tab and save it.
- Submit an application with a one-line use case. The form shows the minimum-length message under that field, and nothing is stored.
- Submit a complete application. It appears under Submissions with every answer, and
interviewreadstrueorfalse. - Check the inbox of each notification recipient. Each gets an email with the fields, and Reply goes to the submitter when the form has an
emailfield. - 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. - Run the scoring script with a secret key and check
cohort.csvlists 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
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.
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.
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.
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.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.