新しい SaaS のウェイトリスト

新しい SaaS のウェイトリスト2 分で読めます

応募者を選考するベータ登録フォームの作り方

サーバーが必須を強制する選考質問付きのベータ応募フォームと、応募者を採点してリストから各コホートを選ぶスクリプトを紹介します。

Preet SutharIkiform 創業者

メールアドレスと一緒にいくつかの選考質問をし、フォームバックエンドに必須を強制させれば、どの応募も漏れなく比較できる形で届きます。あとは短い採点スクリプトでリストから各コホートを選び、少人数を招待して、残りの人には次の回を待ってもらいます。

ウェイトリストはローンチ当日に全員にメールを送ります。ベータはその逆で、20 人を招待して学び、さらに 20 人を招待します。これでフォームが変わります。必要なのはアドレスだけでなく、並べ替えに使える回答です。

ベータで変わること

  • 選考です。 一度に少人数ずつ招待するので、応募者同士を順位付けする方法が必要です。役割やチームの規模などの選択式の質問はきれいに並べ替えられます。自由記述はそうはいきません。
  • 不完全な応募は枠を無駄にします。 合うかどうかを判断する質問を飛ばされると、選ぶことも外すこともできません。選考質問は必須にし、飛ばした送信はサーバーに拒否させましょう。
  • 招待は複数回に分けて送ります。 次のコホートで重複しないよう、リストは誰をすでに招待したかを覚えておく必要があります。Ikiform にはタグがないので、その状態はスクリプトかスプレッドシートで管理します。
  • 選ばれなかった人も大切です。 彼らは次のコホートです。サンクスメッセージで、グループごとに順次招待すると伝えておけば、連絡がなくても不採用だとは受け取られません。

選考質問

質問は 6 つが上限です。どの質問も、誰を招待するかを左右するものにしてください。回答によって順位がどう上下するか説明できない質問は削りましょう。下のフォームを試してみてください。Ikiform がサーバーで強制するのと同じフィールド定義で動くので、不完全な応募が受け取る 422 を確認できます。

プレビュー

Ikiform のバリデーターをブラウザーで実行します。何も送信されません。

チームの規模

初日に任せたい作業を 1〜2 文で書いてください。

この定義をフォームの Fields タブに貼り付けます。use_case の回答は 40 文字以上が必要なので、一言だけの応募を除外できます。
フィールド判断することこの型にする理由
email誰を招待するか必須。仕事用のメールアドレスなら、尋ねなくても会社の見当がつきます
roleあなたが解決する課題を実感しているか独自の区分を持つ select なので、重み付けできます
team_size最初のバージョンが相手の環境に合うかradio: 回答は 1 つで、常に比較できます
use_case今月ベータを使ってくれるか最小文字数付きの textarea。フォームでいちばん正直なシグナルです
current_tool何を置き換えるのか、何と比較されるのか任意の自由記述
interview誰と話せるかcheckbox。未送信は false を意味するので、応募を妨げることはありません
フィールド定義があると、Ikiform は知らないフィールドを 422 と unknown_field エラーで拒否します。ref のような hidden 入力欄を追加する場合は、定義にも追加してください。

フォーム

選考フォームはメールアドレスの誤り以外にもさまざまな理由で失敗するので、以下のどのバージョンでも、サーバーのメッセージを該当するフィールドの下に表示します。送信が拒否されても入力内容は残るので、段落を打ち直す必要はありません。

ネイティブの required と minlength で、送信前にほとんどの漏れを防げます。プレーンなフォームはスクリプトなしで動き、サンクスページに移動します。スクリプトを使うとページにとどまったまま、フィールドごとのエラーを一覧表示します。

beta.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>
</body> の前
<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>
サーバー側のバージョンはシークレットキーを付けて送信するので、Ikiform は Allowed domains とその人間確認をスキップします。その場合、すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計に適用されてしまいます。サーバー経由で送信するフォームには Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。

コホートを選ぶ

応募がベータのコホートになるまで応募漏れなし、サーバーが強制スコア役割、規模、熱意、通話上位 25 人コホート 1、招待済み次の回招待済みは除外待機中順次招待と案内済みエクスポート2 週間選考ラインより下応募がベータのコホートになるまで応募漏れなし、サーバーが強制スコア役割、規模、熱意、通話上位 25 人コホート 1、招待済み次の回招待済みは除外待機中順次招待と案内済みエクスポート2 週間選考ラインより下
  1. 必須の質問を飛ばした送信は Ikiform が拒否するので、応募は漏れなく届きます。
  2. スクリプトが API で応募を読み取り、役割、チームの規模、用途の長さ、通話のチェックボックスでそれぞれ採点します。
  3. 上位 25 人が最初のコホートになり、メールツールから招待を受け取ります。
  4. 2 週間後、招待済みのメールアドレスを渡してスクリプトを再実行し、その人たちを除いて次のグループを選びます。
  5. 選考ラインより下の人はリストに残り、自分の番が来たら連絡を受け取ります。
スコアは出発点となる順位であって、結論ではありません。選考ライン付近の人の用途は自分で読んでください。

このスクリプトは REST API で受信済みの応募をすべて読み取り、メールアドレスごとに最新の 1 件を残して採点し、上位のグループを cohort.csv に書き出します。すでに招待したメールアドレスを引数で渡すと、次の回ではその人たちが除外されます。submissions:read スコープを持つシークレットキーが必要です。

cohort.mjs
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}`);
  • ROLE と SIZE は、最初のバージョンの対象に合わせて変更します。チーム向けのツールなら 2-10 を最高点に、個人向けのツールなら 1 を最高点にするとよいでしょう。
  • 用途は 25 語ごとに 1 点、最大 3 点です。長さは質の指標ではありませんが、3 文書く人はたいてい実際に使ってくれます。
  • すべての値を引用符で囲むので、回答に含まれるカンマで CSV が壊れません。
  • 招待済みのリストは、$(cat invited.txt) で渡せる invited.txt など、見つけやすい場所に保存しておきます。

招待を送る

cohort.csv をメールツールにインポートし、そこから招待を送ります。Ikiform はフォームに入力した人にはメールを送りません。通知メールが届くのはあなたとチームだけなので、自動返信、確認メール、ダブルオプトインはありません。それらは Webhook をきっかけにメールツールから送ってください。最初の招待は短くまとめます。得られるもの、試してほしいこと 1 つ、連絡方法です。Ikiform から届く各通知メールには応募者宛ての Reply があるので、選考ライン付近の人に個別の短いメッセージを送るのに便利です。

動作を確認する

  1. フォームの Allowed domains に、自分のサイトのドメインと、開発中は localhost を追加します。空にしておくと、どこからの送信も受け付けます。
  2. フィールド定義をフォームの Fields タブに貼り付けて保存します。
  3. 用途を 1 行だけ書いた応募を送信します。フォームはそのフィールドの下に最小文字数のメッセージを表示し、何も保存されません。
  4. すべてを埋めた応募を送信します。Submissions にすべての回答付きで表示され、interview は true か false になります。
  5. 通知の受信者それぞれの受信箱を確認します。各自にフィールドを載せたメールが届き、フォームに email フィールドがあれば Reply は送信者宛てになります。
  6. ハニーポットに値を入れて _gotcha=bot で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
  7. シークレットキーを使って採点スクリプトを実行し、cohort.csv にテストの応募とそのスコアが載っていることを確認します。

ハマりどころ

問題対処法
422 validation_failed送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。
ref、utm_source などの hidden 入力欄で Unknown field の 422フィールド定義にそのフィールドがありません。任意の text フィールドとして追加するか、先頭に _ を付けた名前に変えて Ikiform に破棄させてください。
Next.js でエラーのあとに長い回答が消えるReact はサーバーアクションのあとにフォームをリセットします。Next.js のサンプルのように、アクションから回答を返し、各入力欄に defaultValue を設定してください。
採点スクリプトが 401 unauthorized または 403 forbidden を投げる401 はキーがないか失効していること、403 は公開可能キーであるかスコープが足りないことを意味します。シークレットキー(ik_sk_)に submissions:read スコープを付けて使ってください。
送信データはダッシュボードにあるのに、メールも Webhook も届かないスパムとして保存されています。スパムは何も送りません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。

よくある質問

手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com