# Cara membuat formulir pendaftaran beta yang menyaring pelamar

> Formulir lamaran beta dengan pertanyaan penyaringan yang ditegakkan server, dan skrip yang menilai pelamar serta memilih setiap kohort dari daftar.

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

Ajukan beberapa pertanyaan penyaringan di samping email, dan biarkan form backend menegakkannya agar setiap lamaran tiba lengkap dan bisa dibandingkan. Lalu pilih setiap kohort dari daftar dengan skrip penilaian singkat, undang sekelompok kecil, dan biarkan yang lain menunggu putaran berikutnya.

Daftar tunggu mengirim email ke semua orang di hari peluncuran. Beta justru sebaliknya: Anda memasukkan dua puluh orang, belajar dari mereka, lalu memasukkan dua puluh lagi. Itu mengubah formulirnya. Anda butuh jawaban yang bisa diurutkan, bukan hanya alamat.

## Apa yang berubah untuk beta

- **Ini adalah seleksi.** Anda mengundang sedikit orang setiap kali, jadi Anda butuh cara untuk membandingkan peringkat pelamar satu sama lain. Pilihan tertutup, seperti peran dan ukuran tim, mudah diurutkan. Teks bebas tidak.
- **Lamaran yang tidak lengkap membuang satu slot.** Jika seseorang melewatkan pertanyaan yang memberi tahu Anda apakah mereka cocok, Anda tidak bisa memilihnya dan juga tidak bisa mencoretnya. Jadikan pertanyaan penyaringan wajib, dan biarkan server menolak kiriman yang melewatkannya.
- **Undangan dikirim bertahap.** Daftar Anda perlu mengingat siapa yang sudah diundang, agar kohort berikutnya melewati mereka. Ikiform tidak punya tag, jadi status itu disimpan di skrip atau sheet Anda.
- **Orang yang tidak terpilih tetap penting.** Mereka adalah kohort berikutnya. Beri tahu mereka di kalimat terima kasih bahwa kelompok diundang secara bertahap, agar inbox yang sepi tidak terbaca sebagai penolakan.

## Pertanyaan penyaringan

Enam pertanyaan adalah batas atasnya. Setiap pertanyaan harus mengubah siapa yang Anda undang; jika Anda tidak bisa menjelaskan bagaimana sebuah jawaban menaikkan atau menurunkan peringkat seseorang, hapus saja. Coba formulir di bawah. Formulir ini menjalankan definisi field yang sama dengan yang ditegakkan Ikiform di server, jadi Anda bisa melihat `422` yang didapat lamaran yang tidak lengkap.

Formulir: Beta application

- **Email kantor** (`email`, email, wajib)
- **Peran Anda** (`role`, select, wajib)
- **Ukuran tim** (`team_size`, radio, wajib)
- **Untuk apa Anda akan memakainya pertama kali?** (`use_case`, textarea, wajib)
- **Apa yang Anda pakai saat ini?** (`current_tool`, text)
- **Saya bersedia untuk panggilan 20 menit** (`interview`, checkbox)

Tempel definisi ini di tab **Fields** formulir. Jawaban `use_case` butuh minimal 40 karakter, yang menyaring lamaran satu kata.

| Field | Yang ditentukan | Alasan memilih tipe ini |
| --- | --- | --- |
| `email` | Siapa yang diundang | Wajib. Email kantor memberi petunjuk tentang perusahaannya tanpa perlu bertanya |
| `role` | Apakah mereka merasakan masalah yang Anda selesaikan | `select` dengan kelompok buatan Anda sendiri, agar Anda bisa memberi bobot |
| `team_size` | Apakah versi pertama Anda cocok dengan setup mereka | `radio`: satu jawaban, selalu bisa dibandingkan |
| `use_case` | Apakah mereka akan memakai beta bulan ini | `textarea` dengan panjang minimum. Sinyal paling jujur di formulir |
| `current_tool` | Siapa yang Anda gantikan, dan dengan apa mereka akan membandingkan Anda | Teks bebas opsional |
| `interview` | Siapa yang bisa Anda ajak bicara | `checkbox`. Jika tidak ada berarti `false`, jadi tidak pernah menghalangi lamaran |

> Dengan definisi field terpasang, Ikiform menolak field apa pun yang tidak dikenalnya dengan `422` dan error `unknown_field`. Jika Anda menambahkan input tersembunyi seperti `ref`, tambahkan juga ke definisinya.

## Formulirnya

Setiap versi di bawah menampilkan pesan server di bawah field yang bersangkutan, karena formulir penyaringan bisa gagal karena lebih banyak alasan daripada sekadar email yang salah. Setiap versi mempertahankan apa yang sudah diketik orang saat kiriman ditolak, jadi tidak ada yang perlu mengetik ulang satu paragraf.

**HTML**

`required` dan `minlength` bawaan sudah menangkap kebanyakan kekurangan sebelum dikirim. Formulir biasa berfungsi tanpa skrip dan mendarat di halaman terima kasih Anda; skripnya membuat orang tetap di halaman dan menampilkan setiap error field.

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

`Sebelum </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 mereset formulir setelah server action berjalan, yang akan menghapus jawaban panjang saat terjadi `422`. Action mengembalikan jawaban sebagai `values`, dan setiap input membacanya kembali lewat `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**

Tanpa skema `input`, Astro Action menerima `FormData` mentah dan Ikiform yang melakukan validasi. Handler mengembalikan error field sebagai data alih-alih melempar error, agar halaman bisa menempatkan setiap pesan.

`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**

Server function meneruskan setiap isian teks. Formulir dikirim lewat `onSubmit`, jadi input tetap mempertahankan nilainya saat server menjawab dengan error.

`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**

`action` milik route meneruskan kiriman dan mempertahankan status Ikiform dengan `data()`. `<Form>` mengirim tanpa memuat ulang, jadi jawaban tetap di tempatnya.

`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()` mengembalikan error field ke `form`. `use:enhance` hanya mereset formulir setelah berhasil, jadi lamaran yang ditolak tetap menyimpan jawabannya. Deklarasikan `IKIFORM_SECRET_KEY` di `src/env.ts` dengan `defineEnvVars`, seperti di [daftar tunggu SvelteKit](https://ikiform.com/id/guides/waitlist/sveltekit.md#env), agar `$app/env/private` bisa membacanya, dan teruskan `Origin` dari browser agar tes lokal dihitung sebagai kiriman `localhost` gratis.

`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**

Server route membaca `FormData` dengan `readFormData` dan meneruskan status dari Ikiform. `ignoreResponseError` membuat `$fetch` mengembalikan body error alih-alih melempar error.

`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**

Single-page app mengirim langsung ke Ikiform dari browser, jadi tidak ada secret key di kode ini. Pastikan **Allowed domains** diatur ke situs Anda.

`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**

Kiriman browser yang sama di komponen Vue. Formulir tetap punya `action`, jadi masih terkirim jika skripnya gagal.

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

> Versi sisi server mengirim dengan secret key, jadi Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Setiap kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sekaligus. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server.

## Pilih kohort

Diagram: Bagaimana lamaran menjadi kohort beta

1. Lamaran tiba lengkap, karena Ikiform menolak kiriman yang melewatkan pertanyaan wajib.
2. Skrip membacanya lewat API dan menilai masing-masing berdasarkan peran, ukuran tim, panjang use case, dan checkbox panggilan.
3. 25 teratas menjadi kohort pertama dan mendapat undangan dari tool email Anda.
4. Dua minggu kemudian, jalankan skrip lagi dengan email yang sudah diundang, agar mereka dilewati, lalu pilih kelompok berikutnya.
5. Semua yang berada di bawah batas tetap di daftar dan akan Anda kabari saat gilirannya tiba.

Skor adalah urutan awal, bukan keputusan akhir. Baca sendiri use case semua orang yang berada di dekat garis batas.

Skrip membaca setiap lamaran yang diterima dengan [REST API](https://ikiform.com/id/docs/api.md#list-submissions), menyimpan yang terbaru per email, menilainya, dan menulis kelompok teratas ke `cohort.csv`. Berikan email yang sudah Anda undang sebagai argumen agar putaran berikutnya melewatinya. Skrip ini butuh secret key dengan scope `submissions:read`.

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

- Ubah `ROLE` dan `SIZE` sesuai siapa target versi pertama Anda. Tool untuk tim mungkin memberi skor tertinggi untuk `2-10`; tool untuk perorangan, `1`.
- Use case mendapat satu poin per 25 kata, hingga 3. Panjang bukan berarti kualitas, tetapi orang yang menulis tiga kalimat biasanya benar-benar datang.
- Memberi tanda kutip pada setiap nilai mencegah koma di dalam jawaban merusak CSV.
- Simpan daftar yang sudah diundang di tempat yang mudah Anda temukan, seperti `invited.txt` yang Anda berikan dengan `$(cat invited.txt)`.

### Kirim undangan

Impor `cohort.csv` ke tool email Anda dan kirim undangan dari sana. Ikiform tidak mengirim email ke orang yang mengisi formulir. Email notifikasi hanya dikirim ke Anda dan tim Anda, jadi tidak ada autoresponder, email konfirmasi, atau double opt-in. Kirim semua itu dari tool email Anda, dipicu oleh [webhook](https://ikiform.com/id/docs/webhooks.md). Buat undangan pertama singkat: apa yang mereka dapat, satu hal yang ingin Anda minta mereka coba, dan cara menghubungi Anda. Setiap email notifikasi yang dikirim Ikiform kepada Anda punya **Reply** yang ditujukan ke pelamar, cocok untuk catatan pribadi singkat kepada orang-orang di dekat garis batas.

## Pastikan berfungsi

1. Di **Allowed domains** formulir, tambahkan domain situs Anda, ditambah `localhost` selama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja.
2. Tempel definisi field di tab **Fields** formulir dan simpan.
3. Kirim lamaran dengan use case satu baris. Formulir menampilkan pesan panjang minimum di bawah field itu, dan tidak ada yang disimpan.
4. Kirim lamaran yang lengkap. Lamaran muncul di **Submissions** dengan setiap jawaban, dan `interview` berisi `true` atau `false`.
5. Periksa inbox setiap penerima notifikasi. Masing-masing mendapat email berisi field-field tersebut, dan **Reply** ditujukan ke pengirim jika formulir punya field `email`.
6. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.
7. Jalankan skrip penilaian dengan secret key dan pastikan `cohort.csv` mencantumkan lamaran uji Anda beserta skornya.

## Jebakan

| Masalah | Yang harus dilakukan |
| --- | --- |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |
| `422` dengan Unknown field untuk `ref`, `utm_source`, atau input tersembunyi lain | Definisi field tidak mencantumkannya. Tambahkan sebagai field `text` opsional, atau ganti namanya dengan awalan `_` agar Ikiform membuangnya. |
| Jawaban panjang hilang setelah error di Next.js | React mereset formulir setelah server action. Kembalikan jawaban dari action dan atur `defaultValue` di setiap input, seperti contoh Next.js. |
| Skrip penilaian melempar `401 unauthorized` atau `403 forbidden` | `401` berarti key tidak ada atau sudah dicabut; `403` berarti key-nya publishable atau tidak punya scope. Gunakan secret key (`ik_sk_`) dengan scope `submissions:read`. |
| Kiriman ada di dashboard tetapi tidak ada email atau webhook yang datang | Kiriman itu disimpan sebagai spam. Spam tidak memicu apa pun. Buka di filter **Spam** untuk melihat alasannya, dan tandai sebagai bukan spam untuk mengirim email dan webhook-nya. |

## Pertanyaan

### Berapa banyak pertanyaan yang sebaiknya ada di formulir pendaftaran beta?

Tiga sampai enam. Setiap pertanyaan harus mengubah siapa yang Anda undang. Pilihan tertutup yang wajib, seperti peran dan ukuran tim, mudah diurutkan; satu pertanyaan terbuka wajib dengan panjang minimum memberi tahu Anda siapa yang akan benar-benar memakai beta.

### Bisakah Ikiform menolak lamaran yang melewatkan pertanyaan penyaringan?

Bisa. Definisikan field di tab Fields formulir dan tandai sebagai wajib. Kiriman yang melewatkan salah satunya ditolak dengan 422 validation_failed, dan error-nya menyebutkan setiap field, jadi formulir Anda bisa menampilkan pesannya di tempat.

### Bisakah saya menandai pelamar sebagai sudah diundang di Ikiform?

Tidak. Ikiform tidak punya tag atau status kustom. Simpan daftar yang sudah diundang di tempat Anda mengirim undangan, atau di file yang Anda berikan ke skrip penilaian, seperti di atas.

### Apakah Ikiform menghapus kiriman duplikat?

Tidak. Setiap kiriman yang diterima disimpan. Pengulangan identik dalam 24 jam menambah skor spam tetapi tidak menjadikannya spam dengan sendirinya, jadi hapus duplikat berdasarkan email di daftar, database, atau ekspor CSV Anda.

### Apakah Ikiform gratis?

Tidak, tidak ada paket gratis. Pengujian gratis: Anda bisa mengirim 50 kiriman uji per bulan dari localhost sebelum membayar. Untuk go live, Pro mulai $6/bln untuk 500 kiriman, atau Anda bisa bayar sekali untuk 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa.

## Panduan lainnya

- [Daftar tunggu untuk SaaS baru](https://ikiform.com/id/guides/waitlist.md): Bangun daftar tunggu pra-peluncuran di landing page Anda sendiri: formulir, perlindungan spam, duplikat, email konfirmasi, dan ekspor rapi di hari peluncuran.
- [Daftar tunggu aplikasi mobile](https://ikiform.com/id/guides/waitlist/mobile-app.md): Daftar tunggu pra-peluncuran untuk aplikasi iOS atau Android yang menanyakan ponsel yang dipakai, mengirim penguji iPhone ke TestFlight, dan mengisi closed test Play.
- [Daftar peluncuran Product Hunt](https://ikiform.com/id/guides/waitlist/product-hunt.md): Kumpulkan daftar peluncuran berminggu-minggu sebelum Product Hunt, tandai setiap pendaftaran per channel, dan kirim email hari-H dalam dua gelombang mengikuti reset waktu Pasifik.
- [Daftar tunggu Next.js](https://ikiform.com/id/guides/waitlist/nextjs.md): Daftar tunggu Next.js App Router: server action yang meneruskan pendaftaran dengan secret key, useActionState, terima kasih optimistis, dan batas per pengunjung.
- [Daftar tunggu di Framer](https://ikiform.com/id/guides/waitlist/framer.md): Dua cara mengumpulkan pendaftaran daftar tunggu di Framer: formulir Framer dengan webhook ke Ikiform, atau code component yang mempertahankan batas per pengunjung dan pemeriksaan spam.
- [Daftar tunggu di Webflow](https://ikiform.com/id/guides/waitlist/webflow.md): Arahkan Form Block Webflow ke Ikiform, tandai pendaftaran menurut sumbernya dengan field tersembunyi, dan pertahankan success state Webflow dengan skrip singkat untuk setiap formulir.
