# 지원자를 선별하는 베타 신청 폼 만드는 방법

> 서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/waitlist/beta-signup

이메일 옆에 선별 질문을 몇 개 두고, 폼 백엔드가 이를 강제하게 해서 모든 신청이 빠짐없이, 비교 가능한 형태로 도착하게 하세요. 그런 다음 짧은 점수 스크립트로 목록에서 기수별 참가자를 고르고, 소규모 그룹을 초대하고, 나머지는 다음 차례를 기다리게 하세요.

대기자 명단은 출시일에 모두에게 이메일을 보냅니다. 베타는 정반대입니다. 20명을 들이고, 그들에게서 배우고, 다시 20명을 들입니다. 그래서 폼이 달라집니다. 주소만이 아니라 정렬할 수 있는 답변이 필요합니다.

## 베타에서 달라지는 점

- **선발입니다.** 한 번에 몇 명씩 초대하므로 지원자끼리 순위를 매길 방법이 필요합니다. 역할이나 팀 규모 같은 선택형 질문은 깔끔하게 정렬됩니다. 자유 입력은 그렇지 않습니다.
- **불완전한 신청은 자리를 낭비합니다.** 적합한지 알려 주는 질문을 누군가 건너뛰면 그 사람을 뽑을 수도, 뺄 수도 없습니다. 선별 질문을 필수로 만들고, 이를 건너뛴 POST는 서버가 거부하게 하세요.
- **초대는 여러 차례에 걸쳐 나갑니다.** 다음 기수가 이미 초대한 사람을 건너뛰도록 목록이 누구를 초대했는지 기억해야 합니다. Ikiform에는 태그가 없으므로 그 상태는 스크립트나 시트에 둡니다.
- **뽑히지 않은 사람도 중요합니다.** 그들이 다음 기수입니다. 감사 문구에서 그룹별로 차례차례 초대한다고 알려 주세요. 그래야 조용한 받은편지함을 거절로 받아들이지 않습니다.

## 선별 질문

질문은 여섯 개가 한계입니다. 질문마다 누구를 초대할지에 영향을 줘야 합니다. 어떤 답이 그 사람의 순위를 어떻게 올리거나 내릴지 말할 수 없다면 빼세요. 아래 폼을 써 보세요. Ikiform이 서버에서 적용하는 것과 같은 필드 정의를 실행하므로, 불완전한 신청이 받는 `422`를 볼 수 있습니다.

폼: Beta application

- **업무용 이메일** (`email`, email, 필수)
- **역할** (`role`, select, 필수)
- **팀 규모** (`team_size`, radio, 필수)
- **가장 먼저 어디에 쓰실 건가요?** (`use_case`, textarea, 필수)
- **지금은 무엇을 쓰고 계신가요?** (`current_tool`, text)
- **20분 통화에 응할 수 있습니다** (`interview`, checkbox)

이 정의를 폼의 **Fields** 탭에 붙여 넣으세요. `use_case` 답변은 최소 40자가 필요하므로 한 단어짜리 신청은 걸러집니다.

| 필드 | 판단하는 것 | 이 타입을 쓰는 이유 |
| --- | --- | --- |
| `email` | 누구를 초대할지 | 필수. 업무용 이메일은 묻지 않아도 회사를 짐작하게 해 줍니다 |
| `role` | 해결하려는 문제를 실제로 겪고 있는지 | 직접 정한 구간이 있는 `select`라서 가중치를 줄 수 있습니다 |
| `team_size` | 첫 버전이 그들의 환경에 맞는지 | `radio`: 답이 하나라 항상 비교할 수 있습니다 |
| `use_case` | 이번 달에 베타를 쓸지 | 최소 길이가 있는 `textarea`. 폼에서 가장 솔직한 신호입니다 |
| `current_tool` | 무엇을 대체하는지, 무엇과 비교할지 | 선택 자유 입력 |
| `interview` | 누구와 이야기할 수 있는지 | `checkbox`. 없으면 `false`이므로 신청을 막지 않습니다 |

> 필드 정의가 있으면 Ikiform은 모르는 필드를 `422`와 `unknown_field` 오류로 거부합니다. `ref` 같은 숨겨진 입력을 추가한다면 정의에도 추가하세요.

## 폼

선별 폼은 잘못된 이메일 말고도 실패할 이유가 많으므로, 아래 모든 버전은 서버의 메시지를 해당 필드 아래에 보여 줍니다. 또 POST가 거부되어도 입력한 내용을 유지하므로 아무도 문단을 다시 쓸 필요가 없습니다.

**HTML**

네이티브 `required`와 `minlength`가 POST 전에 대부분의 누락을 잡아냅니다. 일반 폼은 스크립트 없이 작동하고 감사 페이지로 이동하며, 스크립트를 쓰면 사람들이 페이지에 머물고 필드 오류가 하나하나 표시됩니다.

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

`</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는 서버 액션이 실행된 뒤 폼을 초기화하므로 `422`가 나면 긴 답변이 지워집니다. 액션이 답변을 `values`로 돌려주고, 각 입력이 `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**

`input` 스키마가 없으면 Astro Action은 원본 `FormData`를 받고 검증은 Ikiform이 합니다. 핸들러는 예외를 던지지 않고 필드 오류를 데이터로 반환하므로 페이지가 각 메시지를 제자리에 표시할 수 있습니다.

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

서버 함수가 모든 텍스트 항목을 전달합니다. 폼은 `onSubmit`으로 제출하므로 서버가 오류로 응답해도 입력값이 유지됩니다.

`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`이 POST를 전달하고 `data()`로 Ikiform의 상태 코드를 유지합니다. `<Form>`은 새로 고침 없이 제출하므로 답변이 그대로 남습니다.

`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()`이 필드 오류를 `form`으로 돌려줍니다. `use:enhance`는 성공한 뒤에만 폼을 초기화하므로 거부된 신청은 답변이 유지됩니다. [SvelteKit 대기자 명단](https://ikiform.com/ko/guides/waitlist/sveltekit.md#env)처럼 `src/env.ts`에서 `defineEnvVars`로 `IKIFORM_SECRET_KEY`를 선언해 `$app/env/private`가 읽을 수 있게 하고, 로컬 테스트가 무료 `localhost` POST로 집계되도록 브라우저의 `Origin`을 전달하세요.

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

서버 라우트가 `readFormData`로 `FormData`를 읽고 Ikiform의 상태 코드를 그대로 전달합니다. `ignoreResponseError`는 `$fetch`가 예외를 던지는 대신 오류 본문을 반환하게 합니다.

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

싱글 페이지 앱은 브라우저에서 Ikiform으로 바로 POST하므로 이 코드에는 시크릿 키가 없습니다. **Allowed domains**를 내 사이트로 설정해 두세요.

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

같은 브라우저 POST를 Vue 컴포넌트로 작성한 것입니다. 폼이 `action`을 유지하므로 스크립트가 실패해도 POST됩니다.

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

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 **Allowed domains**와 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 계산됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

## 기수 선발하기

다이어그램: 신청이 베타 기수가 되는 과정

1. 필수 질문을 건너뛴 POST는 Ikiform이 거부하므로 신청은 빠짐없이 도착합니다.
2. 스크립트가 API로 신청을 읽고 역할, 팀 규모, 사용 사례의 길이, 통화 체크박스로 각각 점수를 매깁니다.
3. 상위 25명이 첫 기수가 되어 이메일 도구에서 초대를 받습니다.
4. 2주 뒤 초대한 이메일을 넣고 스크립트를 다시 실행해 그들을 건너뛰고 다음 그룹을 고릅니다.
5. 기준선 아래의 모든 사람은 목록에 남아 있다가 자기 차례가 오면 연락을 받습니다.

점수는 출발점일 뿐 판정이 아닙니다. 기준선 근처에 있는 사람의 사용 사례는 직접 읽으세요.

스크립트는 [REST API](https://ikiform.com/ko/docs/api.md#list-submissions)로 받은 신청을 모두 읽고, 이메일마다 최신 신청만 남겨 점수를 매기고, 상위 그룹을 `cohort.csv`에 씁니다. 다음 차례에서 건너뛰도록 이미 초대한 이메일을 인수로 넘기세요. `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}`);
```

- 첫 버전의 대상에 맞게 `ROLE`과 `SIZE`를 바꾸세요. 팀용 도구라면 `2-10`에, 1인용 도구라면 `1`에 가장 높은 점수를 줄 수 있습니다.
- 사용 사례는 25단어마다 1점, 최대 3점을 받습니다. 길이가 곧 품질은 아니지만, 세 문장을 쓰는 사람은 대개 실제로 참여합니다.
- 모든 값을 따옴표로 감싸면 답변 속 쉼표가 CSV를 깨뜨리지 않습니다.
- 초대한 목록은 `$(cat invited.txt)`로 넘기는 `invited.txt`처럼 찾기 쉬운 곳에 보관하세요.

### 초대 보내기

`cohort.csv`를 이메일 도구로 가져와 거기서 초대를 보내세요. Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인이 없습니다. 이런 이메일은 [웹훅](https://ikiform.com/ko/docs/webhooks.md)을 트리거로 이메일 도구에서 보내세요. 첫 초대는 짧게 쓰세요. 무엇을 받는지, 꼭 써 봤으면 하는 한 가지, 연락 방법이면 됩니다. Ikiform이 보내는 알림 이메일마다 지원자에게 가는 **Reply**가 있어서, 기준선 근처의 사람들에게 짧은 개인 메모를 보내기에 좋습니다.

## 작동 확인하기

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발 중에는 `localhost`도 추가하세요. 비워 두면 어디서 온 POST든 받습니다.
2. 필드 정의를 폼의 **Fields** 탭에 붙여 넣고 저장하세요.
3. 사용 사례를 한 줄만 써서 신청해 보세요. 폼이 그 필드 아래에 최소 길이 메시지를 보여 주고, 아무것도 저장되지 않습니다.
4. 빠짐없이 작성한 신청을 제출하세요. 모든 답변과 함께 **Submissions**에 나타나고, `interview`는 `true` 또는 `false`입니다.
5. 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받으며, 폼에 `email` 필드가 있으면 **Reply**(답장)는 제출자에게 갑니다.
6. 허니팟을 채워서 `_gotcha=bot`으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
7. 시크릿 키로 점수 스크립트를 실행하고 `cohort.csv`에 테스트 신청이 점수와 함께 있는지 확인하세요.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| `422 validation_failed` | POST가 폼의 필드 정의와 맞지 않습니다. `error.fields`에 각 필드와 이유가 나옵니다. 입력의 `name`을 필드 ID와 맞추세요. |
| `ref`, `utm_source` 또는 다른 숨겨진 입력에 대해 Unknown field와 함께 `422` | 필드 정의에 없습니다. 선택 `text` 필드로 추가하거나, Ikiform이 버리도록 이름 앞에 `_`를 붙이세요. |
| Next.js에서 오류 후 긴 답변이 사라짐 | React는 서버 액션 후 폼을 초기화합니다. Next.js 예제처럼 액션에서 답변을 반환하고 각 입력에 `defaultValue`를 설정하세요. |
| 점수 스크립트가 `401 unauthorized` 또는 `403 forbidden`을 던짐 | `401`은 키가 없거나 폐기되었다는 뜻이고, `403`은 퍼블리셔블 키이거나 스코프가 없다는 뜻입니다. `submissions:read` 스코프가 있는 시크릿 키(`ik_sk_`)를 쓰세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않음 | 스팸으로 저장된 것입니다. 스팸은 아무것도 보내지 않습니다. **Spam** 필터에서 열어 이유를 확인하고, 스팸 아님으로 표시하면 이메일과 웹훅이 전송됩니다. |

## 질문

### 베타 신청 폼에는 질문이 몇 개 있어야 하나요?

3~6개입니다. 질문마다 누구를 초대할지에 영향을 줘야 합니다. 역할이나 팀 규모 같은 필수 선택형 질문은 정렬하기 좋고, 최소 길이가 있는 필수 서술형 질문 하나는 누가 베타를 실제로 쓸지 알려 줍니다.

### Ikiform이 선별 질문을 건너뛴 신청을 거부할 수 있나요?

네. 폼의 Fields 탭에서 필드를 정의하고 필수로 표시하세요. 하나라도 빠진 POST는 422 validation_failed로 거부되고, 오류에 각 필드가 나오므로 폼이 메시지를 제자리에 보여 줄 수 있습니다.

### Ikiform에서 지원자에게 초대됨 태그를 달 수 있나요?

아니요. Ikiform에는 태그나 사용자 지정 상태가 없습니다. 위에서 보여 준 것처럼 초대한 목록은 초대를 보내는 곳이나 점수 스크립트에 넘기는 파일에 보관하세요.

### Ikiform이 중복 제출을 제거하나요?

아니요. 받아들인 POST는 모두 저장됩니다. 24시간 안의 동일한 반복은 스팸 점수를 올리지만 그것만으로 스팸이 되지는 않으므로, 목록, 데이터베이스, CSV 내보내기에서 이메일 기준으로 중복을 제거하세요.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [새 SaaS용 대기자 명단](https://ikiform.com/ko/guides/waitlist.md): 내 랜딩 페이지에 출시 전 대기자 명단을 만듭니다. 폼, 스팸 방지, 중복 처리, 확인 이메일, 출시일의 깔끔한 내보내기까지 다룹니다.
- [모바일 앱 대기자 명단](https://ikiform.com/ko/guides/waitlist/mobile-app.md): iOS나 Android 앱의 출시 전 대기자 명단으로, 사용하는 휴대폰을 묻고 iPhone 테스터는 TestFlight로 보내고 Play 비공개 테스트를 채웁니다.
- [Product Hunt 출시 리스트](https://ikiform.com/ko/guides/waitlist/product-hunt.md): Product Hunt 몇 주 전부터 출시 리스트를 모으고, 가입마다 채널 태그를 달고, 태평양 시간 리셋에 맞춰 출시일 이메일을 두 번에 나눠 보냅니다.
- [Next.js 대기자 명단](https://ikiform.com/ko/guides/waitlist/nextjs.md): Next.js App Router 대기자 명단: 시크릿 키로 가입을 전달하는 서버 액션, useActionState, 낙관적 감사 문구, 방문자별 한도를 다룹니다.
- [Framer 대기자 명단](https://ikiform.com/ko/guides/waitlist/framer.md): Framer에서 대기자 명단 가입을 받는 두 가지 방법: 웹훅으로 Ikiform에 보내는 Framer 폼, 또는 방문자별 한도와 스팸 검사를 유지하는 코드 컴포넌트입니다.
- [Webflow 대기자 명단](https://ikiform.com/ko/guides/waitlist/webflow.md): Webflow Form Block을 Ikiform으로 연결하고, 숨겨진 필드로 가입 경로를 태그하고, 짧은 스크립트로 모든 폼에서 Webflow의 성공 상태를 유지합니다.
