# React Router로 대기자 명단 만들기

> 모든 페이지 푸터에 있는 React Router 대기자 명단: fetcher가 라우트 액션 하나로 POST하고, 가입마다 페이지를 태그하며, JavaScript 없이도 작동합니다.

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

대기자 명단에 전용 라우트를 주고, 이메일을 검증해 시크릿 키로 폼 백엔드에 전달하는 `action`을 두세요. 레이아웃에서 `useFetcher`로 폼을 렌더링하면 어느 페이지에서 가입하든 그 페이지에 머물고 경로가 태그됩니다. JavaScript가 없으면 같은 폼이 대기자 명단 라우트로 POST하고 그 페이지가 결과를 보여 줍니다.

[메인 가이드](https://ikiform.com/ko/guides/waitlist.md#form)에는 홈 라우트에 폼과 `action`을 둔 React Router 탭이 있습니다. 한 페이지짜리 사이트에는 그걸로 충분합니다. 출시 전 SaaS 사이트는 대개 요금제, 블로그 글, 홈 페이지처럼 페이지가 몇 개 있고, 모든 페이지에 가입 폼을 두고 어느 페이지가 효과적이었는지 알고 싶어집니다. 이 페이지는 폼을 레이아웃에 두고 모든 사본을 액션 하나로 POST합니다.

## React Router에서 다른 점

프레임워크 모드에서 액션은 라우트에 속하고, `<Form>`은 자신이 놓인 라우트로 POST합니다. 레이아웃의 폼은 모든 라우트에 놓이므로, 어떤 컴포넌트를 고르느냐에 따라 제출 시 일어나는 일이 달라집니다.

|   | `<Form method="post">` | `<fetcher.Form action="/waitlist">` |
| --- | --- | --- |
| POST하는 곳 | `action`을 지정하지 않으면 현재 라우트 | `/waitlist` 라우트의 액션 |
| 제출 후 | 내비게이션. URL과 히스토리가 바뀜 | 내비게이션 없음. 방문자가 페이지에 머묾 |
| 결과 | 실행된 라우트의 `actionData` | 같은 `key`를 쓰는 모든 fetcher가 공유하는 `fetcher.data` |
| JavaScript 없음 | 일반 POST | 자체 페이지를 렌더링하는 `/waitlist`로 일반 POST |

푸터 가입에는 fetcher가 알맞습니다. 목록에 가입하려고 요금제 페이지를 떠나고 싶은 사람은 없습니다. 기능이 줄어들 때도 깔끔합니다. 하이드레이션 전에는 `fetcher.Form`이 `/waitlist`로 POST하는 일반 `<form>`이므로, 그 라우트에는 결과를 보여 줄 자체 페이지가 필요합니다.

다이어그램: 모든 React Router 페이지의 푸터 가입이 라우트 액션 하나를 거침

1. 레이아웃이 모든 페이지에 대기자 명단 폼을 렌더링하고, 페이지 경로로 만든 숨겨진 ref를 넣습니다.
2. JavaScript가 있으면 fetcher.Form이 페이지를 떠나지 않고 /waitlist로 POST합니다. fetcher의 key가 공유되므로 헤더의 안내 문구도 같은 결과를 봅니다.
3. JavaScript가 없으면 같은 폼이 일반 내비게이션으로 /waitlist에 POST합니다.
4. 어느 쪽이든 /waitlist 액션이 이메일을 검사하고 시크릿 키로 가입을 Ikiform에 전달합니다.
5. fetcher는 fetcher.data에서 결과를 읽습니다. 일반 POST는 /waitlist 페이지로 이동하고, 그 페이지가 actionData로 결과를 보여 줍니다.

액션 하나, 폼은 몇 개든. 경로를 보면 어느 페이지가 가입을 이끌어 냈는지 알 수 있습니다.

## 프로젝트 설정하기

폼과 시크릿 키 만들기는 [서버 가이드](https://ikiform.com/ko/docs/guides/server.md)에서 다룹니다. 이 페이지의 파일은 다음과 같습니다.

`프로젝트`:

```text
app/
  root.tsx
  routes.ts
  routes/home.tsx
  routes/pricing.tsx
  routes/waitlist.tsx
  components/waitlist-form.tsx
react-router.config.ts
.env
```

키는 `.env`에 두고 액션에서 `process.env`로 읽으세요. 라우트 모듈은 브라우저용으로 분리되지만 `action`은 서버에서만 실행되므로 키가 클라이언트로 나가지 않습니다. 절대 컴포넌트에서 가져오지 마세요.

`.env`:

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

## 만들기

### 라우트 등록하기

`app/routes.ts`:

```ts
import { index, type RouteConfig, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("pricing", "routes/pricing.tsx"),
  route("waitlist", "routes/waitlist.tsx"),
] satisfies RouteConfig;
```

### 대기자 명단 라우트

`app/routes/waitlist.tsx`:

```tsx
import { data } from "react-router";
import { WaitlistForm } from "../components/waitlist-form";
import type { Route } from "./+types/waitlist";

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

export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const email = String(form.get("email") ?? "").trim();
  if (!EMAIL.test(email)) {
    return data({ ok: false, message: "Enter the email we should send your invite to.", email }, { status: 400 });
  }
  const ref = String(form.get("ref") ?? "");

  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: request.headers.get("origin") ?? "",
    },
    body: JSON.stringify({
      email,
      ref: SOURCE.test(ref) ? ref : "direct",
      _gotcha: String(form.get("_gotcha") ?? ""),
    }),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

  const result = await response?.json().catch(() => null);
  if (result?.ok) return { ok: true, message: "You're on the list. We'll email you on launch day.", email };
  return data(
    {
      ok: false,
      message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
      email,
    },
    { status: response?.status === 429 ? 429 : 400 },
  );
}

export default function Waitlist({ actionData }: Route.ComponentProps) {
  return (
    <main>
      <h1>Join the Acme waitlist</h1>
      <WaitlistForm initial={actionData} />
    </main>
  );
}
```

- `data()`는 오류를 던지는 응답으로 바꾸지 않고 상태 코드를 설정하므로, 컴포넌트가 여전히 메시지를 렌더링합니다.
- 모든 응답이 이메일을 함께 돌려주므로 오류 후에도 입력란에 다시 표시할 수 있습니다.
- Ikiform은 브라우저의 `Origin` 헤더로 무료 `localhost` 테스트 제출을 세는데 액션 자체의 `fetch`는 이 헤더를 보내지 않으므로, 이를 전달합니다.
- `ref`는 정의와 같은 `max_length`로 영문 소문자, 숫자, `-`, `_`로 된 40자 이하여야 합니다. 그 밖의 값은 `direct`로 저장됩니다.
- 기본 내보내기는 JavaScript가 없을 때의 폴백입니다. 액션 결과와 함께 같은 폼을 렌더링합니다.

### 공유 폼

`app/components/waitlist-form.tsx`:

```tsx
import { useFetcher, useLocation } from "react-router";

interface Outcome {
  ok: boolean;
  message: string;
  email: string;
}

export function sourceFor(pathname: string) {
  const slug = pathname.replace(/^\/+|\/+$/g, "").replace(/\//g, "-").toLowerCase();
  return (slug || "home").slice(0, 40);
}

export function WaitlistForm({ initial }: { initial?: Outcome }) {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  const { pathname } = useLocation();
  const outcome = fetcher.data ?? initial;
  const pending = fetcher.state !== "idle";

  if (outcome?.ok) return <p role="status">{outcome.message}</p>;

  return (
    <fetcher.Form method="post" action="/waitlist">
      <label>
        Email
        <input name="email" type="email" autoComplete="email" required defaultValue={outcome?.email} />
      </label>
      <input type="hidden" name="ref" value={sourceFor(pathname)} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <p role="alert">{outcome?.message}</p>
      <button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
    </fetcher.Form>
  );
}

export function JoinedNote() {
  const fetcher = useFetcher<Outcome>({ key: "waitlist" });
  return fetcher.data?.ok ? <span>You're on the list</span> : <a href="#waitlist">Join the waitlist</a>;
}
```

- `key: "waitlist"`는 그 key를 쓰는 모든 fetcher를 하나의 공유 fetcher로 만듭니다. 헤더의 `JoinedNote`가 같은 `fetcher.data`를 읽으므로, 푸터 폼이 성공하는 순간 바뀝니다.
- `sourceFor`는 `/blog/launch`를 `blog-launch`로, `/`를 `home`으로 바꿉니다. 경로는 서버에서 알 수 있으므로 프리렌더링된 페이지에서도 작동합니다.
- `fetcher.state`는 POST가 실행되는 동안 `submitting`, React Router가 재검증하는 동안 `loading`이므로, 둘 다 끝날 때까지 버튼이 비활성화됩니다.
- 라벨이 입력을 감싸므로 한 페이지의 히어로와 푸터 사본이 `id`를 공유하지 않습니다.

`app/root.tsx`의 `Layout`에서 `id="waitlist"`인 푸터 안에 `<WaitlistForm />`을, 헤더에 `<JoinedNote />`를 렌더링하세요. 허니팟은 함께 실려 손대지 않은 채 Ikiform에 도달합니다. 시크릿 키를 쓴 POST는 Ikiform의 사람 확인을 건너뛰므로 이 폼에서는 **Spam check**를 켜도 아무 효과가 없습니다.

### 모든 loader의 재로드 건너뛰기

액션이 끝나면 React Router는 페이지에 있는 모든 라우트의 데이터를 다시 불러옵니다. 가입은 그중 아무것도 바꾸지 않습니다. loader가 느리거나 비용이 드는 라우트에는 이것을 추가하세요.

`app/routes/pricing.tsx`:

```ts
import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({ formAction, defaultShouldRevalidate }: ShouldRevalidateFunctionArgs) {
  if (formAction === "/waitlist") return false;
  return defaultShouldRevalidate;
}
```

## 렌더링과 배포

마케팅 페이지는 방문자마다 달라지지 않으므로 프리렌더링하고 서버는 액션용으로 남겨 두세요.

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: true,
  prerender: ["/", "/pricing"],
} satisfies Config;
```

`ssr: true`를 유지하세요. `ssr: false`이면 런타임 서버가 없고, React Router는 `action`을 내보내는 라우트가 있으면 빌드를 실패시킵니다. `ssr: true`이면 `/`와 `/pricing`은 빌드 시점에 HTML로 작성되고 `/waitlist`는 여전히 서버에서 실행됩니다.

| 호스트 | 추가할 것 | 키 읽기 |
| --- | --- | --- |
| Vercel | `react-router.config.ts`에 `presets: [vercelPreset()]`를 지정한 `@vercel/react-router` | `process.env`, **Environment Variables**에서 설정 |
| Netlify | Vite 플러그인에 `netlifyReactRouter()`로 넣는 `@netlify/vite-plugin-react-router` | `process.env`, **Environment variables**에서 설정 |
| Cloudflare Workers | `reactRouter()` 앞에 `cloudflare()`로 넣는 `@cloudflare/vite-plugin` | `cloudflare:workers`의 `env.IKIFORM_SECRET_KEY`, `wrangler secret put`으로 설정 |

Cloudflare에서는 `import { env } from "cloudflare:workers"`로 가져와 `process.env` 대신 거기서 키를 읽으세요. React Router 8에서는 액션의 `context`가 `RouterContextProvider`이므로 v7의 `context.cloudflare.env` 패턴은 더 이상 통하지 않습니다. 액션의 나머지는 그대로입니다.

## 작동 확인하기

1. `react-router dev`를 실행하고 `http://localhost:5173/pricing`을 열어 푸터에서 가입하세요. 감사 문구가 폼을 대신하고 헤더 안내 문구도 함께 바뀝니다.
2. 대시보드에서 **Submissions**를 여세요. 가입의 `ref`가 `pricing`으로 설정되어 있습니다.
3. JavaScript를 끄고 홈 페이지에서 가입하세요. 감사 문구가 있는 `/waitlist`로 이동하고, 가입의 `ref`는 `home`으로 설정됩니다.
4. 푸터에서 `a@b`를 제출하세요. 입력한 주소가 그대로 남은 채 입력 아래에 오류가 표시됩니다.
5. 허니팟을 채워서 `_gotcha=bot`으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.
6. `react-router dev`에서 보낸 POST는 `localhost` 출처를 가지므로 테스트 제출로 집계됩니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도 `localhost`에서 보낸 테스트 제출을 매달 50건 받습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 내세요. 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| JavaScript가 켜져 있는데도 푸터 가입이 `/waitlist`로 이동함 | 폼이 `fetcher.Form`이 아니라 `<Form>`이나 일반 `<form>`입니다. fetcher를 쓰고, 액션에서 `redirect()`를 반환하지 마세요. fetcher 액션의 리디렉션은 내비게이션을 일으킵니다. |
| `action` 내보내기에 관한 오류로 빌드가 실패함 | `react-router.config.ts`에서 `ssr`가 `false`입니다. `true`로 설정하지 않으면 대기자 명단을 실행할 서버가 없습니다. |
| 모든 가입의 `ref`가 `waitlist`임 | POST가 `/waitlist` 페이지 자체에서 옵니다. 그건 폴백 페이지이니, 링크는 실제 페이지를 가리켜야 합니다. |
| `unknown_field`와 함께 `422` | 필드 정의에 `ref`가 없습니다. 위의 정의처럼 추가하세요. |
| `402 plan_required` | 워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. **Settings › Plan and usage**에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |

## 질문

### React Router 폼을 내비게이션 없이 제출하려면 어떻게 하나요?

useFetcher를 쓰고, 처리할 라우트의 액션으로 fetcher.Form을 렌더링하세요. 방문자는 페이지에 머물고 결과는 fetcher.data로 옵니다. 하이드레이션 전에는 일반 폼으로 POST합니다.

### 프리렌더링된 React Router 페이지에서 폼이 작동하나요?

ssr이 true이면 작동합니다. 프리렌더링된 페이지는 HTML로 제공되고, 그 폼은 서버에서 실행되는 액션으로 POST합니다. ssr을 false로 설정하면 액션을 쓸 수 없습니다.

### React Router 가입이 어느 페이지에서 왔는지 어떻게 알 수 있나요?

위의 sourceFor처럼 페이지 경로를 숨겨진 ref 입력에 넣으세요. 경로는 서버와 클라이언트 모두에서 알 수 있으므로 프리렌더링된 페이지와 하이드레이션 전에도 작동합니다.

### 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, 낙관적 감사 문구, 방문자별 한도를 다룹니다.
- [TanStack Start 대기자 명단](https://ikiform.com/ko/guides/waitlist/tanstack-start.md): TanStack Start 대기자 명단: 서버 라우트 하나가 일반 폼 POST와 fetch에 모두 응답하고, validateSearch가 유입 경로와 결과를 URL로 전달합니다.
- [일반 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): 서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.
