Warteliste für ein neues SaaS7 Min. Lesezeit
So baust du ein Beta-Anmeldeformular, das Bewerber vorab prüft
Ein Beta-Bewerbungsformular mit Auswahlfragen, die der Server durchsetzt, und ein Skript, das Bewerber bewertet und jede Kohorte aus der Liste auswählt.
Stell neben der E-Mail ein paar Auswahlfragen und lass das Formular-Backend sie durchsetzen, damit jede Bewerbung vollständig und vergleichbar ankommt. Wähle dann jede Kohorte mit einem kurzen Bewertungsskript aus der Liste aus, lade eine kleine Gruppe ein und lass alle anderen auf die nächste Runde warten.
Eine Warteliste schreibt am Launch-Tag allen. Eine Beta macht das Gegenteil: Du lässt zwanzig Leute rein, lernst von ihnen und lässt dann zwanzig weitere rein. Das ändert das Formular. Du brauchst Antworten, nach denen du sortieren kannst, nicht nur eine Adresse.
Was sich für eine Beta ändert
- Es ist eine Auswahl. Du lädst jeweils wenige Leute ein, brauchst also eine Möglichkeit, Bewerber gegeneinander zu ranken. Geschlossene Auswahlfragen wie Rolle und Teamgröße lassen sich sauber sortieren. Freitext nicht.
- Unvollständige Bewerbungen kosten einen Platz. Wenn jemand die Frage überspringt, die dir sagt, ob die Person passt, kannst du sie weder auswählen noch aussortieren. Mach die Auswahlfragen zu Pflichtfeldern und lass den Server Posts ablehnen, die sie überspringen.
- Einladungen gehen in Runden raus. Deine Liste muss sich merken, wen du schon eingeladen hast, damit die nächste Kohorte diese Leute überspringt. Ikiform hat keine Tags, dieser Zustand lebt also in deinem Skript oder deiner Tabelle.
- Wer nicht ausgewählt wurde, zählt trotzdem. Das ist deine nächste Kohorte. Sag den Leuten in der Dankeszeile, dass Gruppen nach und nach eingeladen werden, damit ein stilles Postfach nicht wie eine Absage wirkt.
Auswahlfragen
Sechs Fragen sind die Obergrenze. Jede sollte ändern, wen du einlädst; wenn du nicht sagen kannst, wie eine Antwort jemanden nach oben oder unten bringt, streich sie. Probier unten das Formular aus. Es nutzt dieselbe Felddefinition, die Ikiform auf dem Server durchsetzt, du siehst also den 422, den eine unvollständige Bewerbung bekommt.
Vorschau
Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.
use_case braucht mindestens 40 Zeichen, was Bewerbungen mit einem Wort aussiebt.| Feld | Was es entscheidet | Warum dieser Typ |
|---|---|---|
email | Wen du einlädst | Pflicht. Eine geschäftliche E-Mail verrät die Firma, ohne zu fragen |
role | Ob sie das Problem spüren, das du löst | Ein select mit deinen eigenen Kategorien, damit du sie gewichten kannst |
team_size | Ob deine erste Version zu ihrem Setup passt | Ein radio: eine Antwort, immer vergleichbar |
use_case | Ob sie die Beta diesen Monat nutzen werden | Eine textarea mit Mindestlänge. Das ehrlichste Signal im Formular |
current_tool | Wen du ablöst und womit sie dich vergleichen werden | Optionaler Freitext |
interview | Mit wem du sprechen kannst | Eine checkbox. Fehlt sie, gilt false, sie blockiert also nie eine Bewerbung |
422 und einem unknown_field-Fehler ab. Wenn du ein verstecktes Eingabefeld wie ref hinzufügst, nimm es auch in die Definition auf.Das Formular
Jede Version unten zeigt die Meldung des Servers unter dem Feld, zu dem sie gehört, weil ein Auswahlformular aus mehr Gründen scheitert als an einer falschen E-Mail. Jede behält, was die Person eingegeben hat, wenn ein Post abgelehnt wird, damit niemand einen Absatz neu tippt.
Natives required und minlength fangen die meisten Lücken vor dem Post ab. Das einfache Formular funktioniert ohne Skript und landet auf deiner Dankeseite; das Skript hält die Leute auf der Seite und listet jeden Feldfehler auf.
<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 setzt ein Formular zurück, nachdem eine Server Action gelaufen ist, was bei einem 422 eine lange Antwort löschen würde. Die Action gibt die Antworten als values zurück, und jedes Eingabefeld liest sie über defaultValue wieder ein.
"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>
);
}Ohne input-Schema bekommt eine Astro Action die rohen FormData, und Ikiform übernimmt die Validierung. Der Handler gibt Feldfehler als Daten zurück, statt zu werfen, damit die Seite jede Meldung platzieren kann.
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>
)}Die Serverfunktion leitet jeden Texteintrag weiter. Das Formular sendet über onSubmit, seine Eingabefelder behalten also ihre Werte, wenn der Server mit Fehlern antwortet.
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>
);
}Die action der Route leitet den Post weiter und behält Ikiforms Status mit data(). <Form> sendet ohne Neuladen, die Antworten bleiben also stehen.
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() gibt die Feldfehler an form zurück. use:enhance setzt das Formular nur nach einem Erfolg zurück, eine abgelehnte Bewerbung behält also ihre Antworten. Deklariere IKIFORM_SECRET_KEY in src/env.ts mit defineEnvVars, wie in der SvelteKit-Warteliste, damit $app/env/private ihn lesen kann, und reiche den Origin des Browsers weiter, damit lokale Tests als kostenlose localhost-Posts zählen.
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}Die Server-Route liest die FormData mit readFormData und reicht Ikiforms Status durch. ignoreResponseError lässt $fetch den Fehler-Body zurückgeben, statt zu werfen.
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>Eine Single-Page-App sendet direkt aus dem Browser an Ikiform, in diesem Code steht also kein Secret Key. Lass Allowed domains auf deine Site gesetzt.
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>
);
}Derselbe Browser-Post in einer Vue-Komponente. Das Formular behält seine action, es sendet also auch, wenn das Skript scheitert.
<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 reinen Server-Umgebungsvariablen.Eine Kohorte auswählen
- Bewerbungen kommen vollständig an, weil Ikiform Posts ablehnt, die eine Pflichtfrage überspringen.
- Ein Skript liest sie über die API und bewertet jede nach Rolle, Teamgröße, Länge des Anwendungsfalls und der Gesprächs-Checkbox.
- Die besten 25 werden die erste Kohorte und bekommen eine Einladung aus deinem E-Mail-Tool.
- Führe das Skript zwei Wochen später erneut mit den eingeladenen E-Mails aus, damit sie übersprungen werden, und wähle die nächste Gruppe.
- Alle unter der Grenze bleiben auf der Liste und hören von dir, wenn ihre Runde kommt.
Das Skript liest jede empfangene Bewerbung mit der REST-API, behält die neueste pro E-Mail, bewertet sie und schreibt die beste Gruppe in cohort.csv. Übergib die E-Mails, die du schon eingeladen hast, als Argumente, damit die nächste Runde sie überspringt. Es braucht einen Secret Key mit dem Scope submissions:read.
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}`);- Passe
ROLEundSIZEdaran an, für wen deine erste Version gedacht ist. Ein Team-Tool könnte2-10am höchsten bewerten; ein Solo-Tool1. - Der Anwendungsfall bringt einen Punkt pro 25 Wörter, bis zu 3. Länge ist nicht Qualität, aber wer drei Sätze schreibt, taucht meist auch auf.
- Jeden Wert in Anführungszeichen zu setzen verhindert, dass Kommas in Antworten die CSV kaputt machen.
- Speichere die Liste der Eingeladenen an einem Ort, an dem du sie wiederfindest, etwa in einer
invited.txt, die du mit$(cat invited.txt)übergibst.
Die Einladung senden
Importiere cohort.csv in dein E-Mail-Tool und sende die Einladung von dort. Ikiform schreibt der Person, die das Formular ausfüllt, keine E-Mail. Benachrichtigungs-E-Mails gehen nur an dich und dein Team, es gibt also keinen Autoresponder, keine Bestätigungs-E-Mail und kein Double-Opt-in. Sende diese aus deinem E-Mail-Tool, ausgelöst durch einen Webhook. Halte die erste Einladung kurz: was sie bekommen, das eine, was sie ausprobieren sollen, und wie sie dich erreichen. Jede Benachrichtigungs-E-Mail, die Ikiform dir sendet, hat ein Reply, das an den Bewerber geht, was zu einer kurzen persönlichen Nachricht an die Leute nahe der Grenze passt.
Prüfen, ob es funktioniert
- Füge unter Allowed domains des Formulars die Domain deiner Site hinzu, plus
localhost, während du entwickelst. Lass das Feld leer, um Posts von überall anzunehmen. - Füge die Felddefinition im Tab Fields des Formulars ein und speichere sie.
- Sende eine Bewerbung mit einem einzeiligen Anwendungsfall ab. Das Formular zeigt die Mindestlängen-Meldung unter diesem Feld, und nichts wird gespeichert.
- Sende eine vollständige Bewerbung ab. Sie erscheint unter Submissions mit jeder Antwort, und
interviewlautettrueoderfalse. - Prüfe das Postfach jedes Benachrichtigungsempfängers. Jeder bekommt eine E-Mail mit den Feldern, und Reply geht an den Absender, wenn das Formular ein
email-Feld hat. - Sende einen Post mit ausgefülltem Honeypot,
_gotcha=bot. Er bekommt trotzdem eine Erfolgsantwort, aber die Einsendung wird als Spam gespeichert und löst weder E-Mail noch Webhook aus. - Führe das Bewertungsskript mit einem Secret Key aus und prüfe, dass
cohort.csvdeine Testbewerbung mit ihrem Score enthält.
Stolperfallen
| Problem | Was zu tun ist |
|---|---|
422 validation_failed | Der Post passt nicht zur Felddefinition des Formulars. error.fields nennt jedes Feld und den Grund. Gleiche den name des Eingabefelds an die Feld-ID an. |
422 mit Unknown field für ref, utm_source oder ein anderes verstecktes Eingabefeld | Die Felddefinition listet es nicht auf. Füge es als optionales text-Feld hinzu oder benenne es mit einem führenden _ um, damit Ikiform es verwirft. |
| Eine lange Antwort verschwindet in Next.js nach einem Fehler | React setzt das Formular nach einer Server Action zurück. Gib die Antworten aus der Action zurück und setze defaultValue an jedem Eingabefeld, wie es das Next.js-Beispiel tut. |
Das Bewertungsskript wirft 401 unauthorized oder 403 forbidden | Ein 401 heißt, der Schlüssel fehlt oder wurde widerrufen; ein 403 heißt, er ist publishable oder hat den Scope nicht. Nutze einen Secret Key (ik_sk_) mit dem Scope submissions:read. |
| Die Einsendung ist im Dashboard, aber keine E-Mail und kein Webhook kam an | Sie wurde als Spam gespeichert. Spam löst nichts aus. Öffne sie unter dem Filter Spam, um die Gründe zu sehen, und markiere sie als kein Spam, um ihre E-Mail und Webhooks zu senden. |
Fragen
Drei bis sechs. Jede Frage sollte ändern, wen du einlädst. Geschlossene Pflichtfragen wie Rolle und Teamgröße lassen sich gut sortieren; eine offene Pflichtfrage mit Mindestlänge zeigt dir, wer die Beta nutzen wird.
Ja. Definiere die Felder im Tab Fields des Formulars und markiere sie als Pflicht. Ein Post, dem eines fehlt, wird mit 422 validation_failed abgelehnt, und der Fehler nennt jedes Feld, damit dein Formular die Meldung an Ort und Stelle zeigen kann.
Nein. Ikiform hat keine Tags oder eigenen Status. Halte die Liste der Eingeladenen dort, wo du Einladungen sendest, oder in einer Datei, die du dem Bewertungsskript übergibst, wie oben gezeigt.
Nein. Jeder angenommene Post wird gespeichert. Eine identische Wiederholung innerhalb von 24 Stunden erhöht den Spam-Score, ist für sich allein aber kein Spam, also dedupliziere per E-Mail-Adresse in deiner Liste, deiner Datenbank oder deinem CSV-Export.
Nein, es gibt keinen kostenlosen Tarif. Testen ist kostenlos: Du kannst 50 Testeinsendungen pro Monat von localhost senden, bevor du zahlst. Für den Livebetrieb startet Pro bei $6/Monat für 500 Einsendungen, oder du zahlst einmalig 10.000 Einsendungen für $49 oder 50.000 Einsendungen für $149, mit Guthaben, das nie verfällt.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.