# TanStack Start로 대기자 명단 만들기

> TanStack Start 대기자 명단: 서버 라우트 하나가 일반 폼 POST와 fetch에 모두 응답하고, validateSearch가 유입 경로와 결과를 URL로 전달합니다.

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

`/api/waitlist`에 서버 라우트를 추가해 이메일을 검증하고, 시크릿 키로 가입을 폼 백엔드에 전달하고, `fetch`에는 JSON으로, 일반 폼 POST에는 `303` 리디렉션으로 응답하게 하세요. 랜딩 라우트는 `validateSearch`로 `?ref=`, `?joined=`, `?problem=`을 읽으므로, JavaScript가 있든 없든 유입 경로 태그와 결과가 유지됩니다.

[메인 가이드](https://ikiform.com/ko/guides/waitlist.md#form)에는 `createServerFn`과 `useServerFn`으로 만든 TanStack Start 탭이 있습니다. 짧지만, 폼은 React가 하이드레이션된 뒤에만 작동하고 가입마다 유입 경로가 고정 문자열입니다. 이 페이지는 출시일에 공유할 버전을 만듭니다. 하이드레이션 전에도 POST하고, 들어온 링크로 가입마다 태그를 달고, 결과를 URL에 유지합니다.

## TanStack Start에서 다른 점

Start에서는 서버에서 코드를 실행하는 방법이 두 가지이며, 폼에서는 서로 다르게 동작합니다.

|   | 서버 함수 | 서버 라우트 |
| --- | --- | --- |
| 정의 방법 | `createServerFn().handler()` | `server.handlers`를 넣은 `createFileRoute()` |
| 호출하는 쪽 | 내 컴포넌트, RPC 호출로 | 일반 `<form>`을 포함한 모든 HTTP 클라이언트 |
| 응답 | 호출자에 맞게 직렬화 | 직접 만드는 `Response` |
| JavaScript 없음 | `.url`을 통해 가능하지만 응답은 제어할 수 없음 | JSON이나 페이지로 돌아가는 리디렉션 중 선택 |

대기자 명단 POST는 두 종류의 클라이언트에서 옵니다. 스크립트를 실행하는 브라우저와 아직 스크립트를 로드하지 않은 브라우저입니다. 서버 라우트는 핸들러 하나로 각각이 기대하는 방식으로 응답할 수 있습니다. 둘은 `Accept` 헤더로 구분합니다. 스크립트는 `application/json`을 요청하고, 일반 폼 POST는 그렇지 않습니다.

다이어그램: TanStack Start 서버 라우트가 POST 종류별로 응답하는 방식

1. 랜딩 라우트가 validateSearch로 ?ref=를 읽어 숨겨진 입력에 렌더링합니다.
2. 폼이 FormData를 서버 라우트로 POST합니다. 스크립트가 로드되었으면 fetch로, 아니면 일반 POST로 보냅니다.
3. 라우트가 이메일과 유입 경로 태그를 검사한 뒤 시크릿 키로 가입을 Ikiform에 전달합니다.
4. fetch POST는 JSON을 요청했으므로 { ok }나 메시지를 받고, 컴포넌트가 감사 문구나 오류로 바꿉니다.
5. 일반 POST는 ?joined=yes나 ?problem= 코드, 그리고 같은 ref와 함께 랜딩 페이지로 돌아가는 303을 받습니다. validateSearch가 이를 읽고 페이지가 결과를 렌더링합니다.

핸들러 하나, 응답 둘. JavaScript가 없는 방문자에게는 URL이 결과를 전달합니다.

## 프로젝트 설정하기

먼저 폼과 시크릿 키를 만드세요. [서버 가이드](https://ikiform.com/ko/docs/guides/server.md)에서 둘 다 다룹니다. 수정할 파일은 다음과 같습니다.

`프로젝트`:

```text
src/
  routes/
    __root.tsx
    index.tsx
    api/waitlist.ts
  waitlist-messages.ts
vite.config.ts
.env.local
```

키는 `.env.local`에 저장하세요. Start가 개발 중에 이 파일을 불러오고, git에는 들어가지 않습니다. 서버 코드는 `process.env`에서 읽습니다. 절대 `VITE_` 접두사를 붙이지 마세요. 그러면 브라우저 코드에 번들링됩니다.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

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

라우트는 `email`과 `ref`를 보냅니다. 폼의 **Fields** 탭에 필드 정의를 추가한다면 둘 다 넣으세요.

`필드`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## 만들기

### 오류 문구가 아니라 오류 코드

`src/waitlist-messages.ts`:

```ts
export type Problem = "email" | "busy" | "failed";

export const MESSAGES: Record<Problem, string> = {
  email: "Enter the email we should send your invite to.",
  busy: "Lots of people are joining. Try again in a minute.",
  failed: "Couldn't join the list. Try again.",
};

export function isProblem(value: unknown): value is Problem {
  return typeof value === "string" && value in MESSAGES;
}
```

리디렉션은 메시지 자체가 아니라 짧은 코드를 URL에 넣습니다. 그러면 `?problem=`이 있는 링크는 여러분이 쓴 세 문장 중 하나만 보여 줄 수 있고, 낯선 사람이 URL에 입력한 아무 문구나 보여 줄 수 없습니다.

### 서버 라우트

`src/routes/api/waitlist.ts`:

```ts
import { createFileRoute } from "@tanstack/react-router";
import { MESSAGES, type Problem } from "../../waitlist-messages";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SOURCE = /^[a-z0-9_-]{1,40}$/;

async function forward(request: Request, form: FormData): Promise<Problem | null> {
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) return "email";
  const ref = String(form.get("ref") ?? "");
  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({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  if (!response) return "failed";
  if (response.status === 429) return "busy";
  const result = await response.json().catch(() => null);
  if (result?.ok) return null;
  return result?.error?.fields?.email ? "email" : "failed";
}

export const Route = createFileRoute("/api/waitlist")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const form = await request.formData();
        const problem = await forward(request, form);

        if (request.headers.get("accept")?.includes("application/json")) {
          return problem
            ? Response.json({ ok: false, message: MESSAGES[problem] }, { status: problem === "busy" ? 429 : 400 })
            : Response.json({ ok: true });
        }

        const back = new URL("/", request.url);
        back.searchParams.set("ref", String(form.get("ref") ?? ""));
        if (problem) back.searchParams.set("problem", problem);
        else back.searchParams.set("joined", "yes");
        return Response.redirect(back, 303);
      },
    },
  },
});
```

- `request.formData()`는 두 인코딩을 모두 읽습니다. `fetch`가 `FormData` 본문으로 보내는 `multipart/form-data`와 일반 POST의 urlencoded 본문입니다.
- `process.env`는 핸들러 안에서 요청 시점에 읽습니다. Cloudflare Workers에서는 아래 `wrangler.jsonc`처럼 `nodejs_compat`가 켜져 있고 호환성 날짜가 2025-04-01 이후일 때만 시크릿이 들어 있습니다.
- Ikiform은 브라우저의 `Origin` 헤더로 무료 `localhost` 테스트 제출을 세는데 라우트 자체의 `fetch`는 이 헤더를 보내지 않으므로, 이를 전달합니다.
- `ref`는 정의의 `max_length`에 맞춰 영문 소문자, 숫자, `-`, `_`로 된 40자 이하여야 합니다. 그 밖의 값은 `direct`로 저장됩니다.
- `Response.redirect(back, 303)`은 브라우저가 GET으로 랜딩 페이지를 불러오게 하므로, 새로 고침해도 가입이 두 번 POST되지 않습니다.

### 랜딩 라우트

`src/routes/index.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";
import { isProblem, MESSAGES, type Problem } from "../waitlist-messages";

interface Search {
  ref?: string;
  joined?: boolean;
  problem?: Problem;
}

export const Route = createFileRoute("/")({
  validateSearch: (search: Record<string, unknown>): Search => ({
    ref: typeof search.ref === "string" ? search.ref.toLowerCase().slice(0, 40) : undefined,
    joined: search.joined === "yes" ? true : undefined,
    problem: isProblem(search.problem) ? search.problem : undefined,
  }),
  component: Home,
});

function Home() {
  const search = Route.useSearch();
  const [joined, setJoined] = useState(Boolean(search.joined));
  const [error, setError] = useState(search.problem ? MESSAGES[search.problem] : "");
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setError("");
    try {
      const response = await fetch("/api/waitlist", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const outcome = await response.json();
      if (outcome.ok) setJoined(true);
      else setError(outcome.message);
    } catch {
      setError("Couldn't reach the server. Try again.");
    } finally {
      setPending(false);
    }
  }

  if (joined) return <p role="status">You're on the list. We'll email you on launch day.</p>;

  return (
    <form method="post" action="/api/waitlist" onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required placeholder="you@company.com" />
      <input type="hidden" name="ref" value={search.ref ?? "direct"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{error}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </form>
  );
}
```

- `validateSearch`는 원본 쿼리를 타입이 있는 객체로 바꿉니다. 알 수 없는 값은 `undefined`가 되므로, 깨진 링크는 일반 폼을 렌더링합니다.
- 숨겨진 `ref` 입력은 서버에서 검색 매개변수로 채웁니다. 일반 POST는 스크립트 없이도 이 값을 전달합니다.
- 폼에는 실제 `method`와 `action`이 있습니다. 번들이 로드되지 않았으면 브라우저가 서버 라우트로 POST하고 리디렉션을 따릅니다.
- 하이드레이션되면 `onSubmit`이 이어받아 `Accept: application/json`을 보내므로 방문자는 페이지에 머뭅니다.

허니팟은 같은 `FormData`에 실려 손대지 않은 채 Ikiform으로 가므로, 이를 채운 봇은 스팸으로 저장됩니다. 시크릿 키를 쓴 POST는 Ikiform의 사람 확인을 건너뛰므로 **Spam check**를 켜도 여기서는 아무 효과가 없습니다. 이 라우트가 관문입니다.

## 렌더링과 배포

랜딩 라우트는 Start의 기본값인 서버 렌더링으로 두세요. 쿼리 문자열을 읽어 `ref`와 결과를 렌더링하는데, 프리렌더링된 페이지는 쿼리 없이 한 번만 빌드되기 때문입니다. 다른 페이지를 위해 `tanstackStart()` 플러그인에서 `prerender`를 켠다면 `/`는 제외하세요.

| 호스트 | 추가할 것 | 시크릿 |
| --- | --- | --- |
| Vercel | `nitro` 패키지의 `nitro()` Vite 플러그인 | 프로젝트 설정의 **Environment Variables** |
| Netlify | Vite 플러그인에 `netlify()`로 넣는 `@netlify/vite-plugin-tanstack-start` | **Site configuration › Environment variables**에서 설정 |
| Cloudflare Workers | `viteEnvironment: { name: "ssr" }`을 지정한 `@cloudflare/vite-plugin`과 `wrangler.jsonc` | `wrangler secret put IKIFORM_SECRET_KEY` |

`wrangler.jsonc`:

```json
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "acme-waitlist",
  "compatibility_date": "2025-09-02",
  "compatibility_flags": ["nodejs_compat"],
  "main": "@tanstack/react-start/server-entry"
}
```

`nodejs_compat`는 Workers에서 `process.env`를 쓸 수 있게 합니다. 없으면 키가 비어 모든 POST가 `failed`로 실패합니다.

## 작동 확인하기

1. `vite dev`를 실행하고 `http://localhost:3000/?ref=test`를 열어 가입하세요. 새로 고침 없이 감사 문구가 폼을 대신합니다.
2. 대시보드에서 **Submissions**를 여세요. `ref`가 `test`로 설정된 가입이 있습니다.
3. JavaScript를 끄고 같은 URL을 새로 고친 뒤 다시 가입하세요. 주소 표시줄에 `?joined=yes`가 표시되고, 두 번째 가입에도 `ref`가 `test`로 설정됩니다.
4. JavaScript를 끈 상태로 `a@b`를 제출하고 페이지가 이메일 메시지, `?problem=email`과 함께 돌아오는지 확인하세요.
5. 허니팟을 채워서 `_gotcha=bot`으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
6. `vite dev`에서 보낸 POST는 `localhost` 출처를 가지므로 테스트 제출로 집계됩니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도 `localhost`에서 보낸 테스트 제출을 매달 50건 받습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 내세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 일반 POST에서 원본 JSON이 표시됨 | 브라우저가 `Accept` 헤더에 `application/json`을 보냈는데, 일반 폼 POST는 절대 그러지 않습니다. 확장 프로그램이나 프록시가 헤더를 바꾸지 않는지 확인하거나, 대신 `fetch`가 보내는 커스텀 헤더로 구분하세요. |
| 프로덕션에서 모든 POST가 `failed`로 응답함 | 호스트에 `IKIFORM_SECRET_KEY`가 설정되지 않았습니다. Workers라면 `nodejs_compat`가 켜져 있고 호환성 날짜가 2025-04-01 이후인지도 확인하세요. 그렇지 않으면 `process.env`가 비어 있습니다. |
| 두 파일이 `/api/waitlist`를 차지함 | Start는 경로마다 핸들러 파일을 하나만 허용합니다. `api/waitlist.ts`와 `api/waitlist/index.ts`가 모두 그 경로로 해석되므로 하나만 남기세요. |
| `unknown_field`와 함께 `422` | 필드 정의에 `ref`가 없습니다. 위의 정의처럼 추가하세요. |
| `402 plan_required` | 워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. **Settings › Plan and usage**에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |

## 질문

### TanStack Start 폼에는 서버 함수와 서버 라우트 중 무엇을 써야 하나요?

둘 다 가입을 전달할 수 있습니다. 서버 라우트는 페이지로 돌아가는 리디렉션처럼 일반 폼 POST에 대한 응답을 고를 수 있어서 하이드레이션 전에도 폼이 작동합니다. 서버 함수는 React가 로드된 뒤에만 실행되는 폼에 알맞습니다.

### TanStack Start에서 가입 유입 경로는 어떻게 유지하나요?

랜딩 라우트에서 validateSearch로 ?ref=를 읽어 숨겨진 입력에 렌더링하고, 전달하기 전에 서버에서 다시 검사하세요. 일반 POST 후의 리디렉션이 이를 URL에 되돌려 놓습니다.

### TanStack Start 랜딩 페이지를 프리렌더링할 수 있나요?

쿼리 문자열에서 유입 경로나 결과를 렌더링한다면 안 됩니다. 프리렌더링된 페이지는 쿼리 없이 빌드되기 때문입니다. 그 라우트는 서버 렌더링으로 두고 나머지를 프리렌더링하세요.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

- [새 SaaS용 대기자 명단](https://ikiform.com/ko/guides/waitlist.md): 내 랜딩 페이지에 출시 전 대기자 명단을 만듭니다. 폼, 스팸 방지, 중복 처리, 확인 이메일, 출시일의 깔끔한 내보내기까지 다룹니다.
- [Next.js 대기자 명단](https://ikiform.com/ko/guides/waitlist/nextjs.md): Next.js App Router 대기자 명단: 시크릿 키로 가입을 전달하는 서버 액션, useActionState, 낙관적 감사 문구, 방문자별 한도를 다룹니다.
- [React Router 대기자 명단](https://ikiform.com/ko/guides/waitlist/react-router.md): 모든 페이지 푸터에 있는 React Router 대기자 명단: fetcher가 라우트 액션 하나로 POST하고, 가입마다 페이지를 태그하며, JavaScript 없이도 작동합니다.
- [일반 HTML 대기자 명단](https://ikiform.com/ko/guides/waitlist/html.md): 빌드 없이 일반 HTML로 만든 완전한 대기자 명단 페이지로, JavaScript가 꺼져도 작동하고, 스크립트가 로드되면 인라인 감사 문구를 보여 주며, 필요할 때는 기본 방식으로 돌아갑니다.
- [Product Hunt 출시 리스트](https://ikiform.com/ko/guides/waitlist/product-hunt.md): Product Hunt 몇 주 전부터 출시 리스트를 모으고, 가입마다 채널 태그를 달고, 태평양 시간 리셋에 맞춰 출시일 이메일을 두 번에 나눠 보냅니다.
- [선별 기능이 있는 베타 신청](https://ikiform.com/ko/guides/waitlist/beta-signup.md): 서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.
