# Nuxt로 대기자 명단 만들기

> 프리렌더링된 랜딩 페이지의 Nuxt 대기자 명단: Nitro 라우트 하나가 $fetch와 일반 폼 POST를 받아 Zod로 검사하고, JavaScript 없이 리디렉션합니다.

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

`routeRules`로 랜딩 페이지를 프리렌더링하고, 폼을 Nitro 서버 라우트로 POST하세요. 라우트는 Zod로 이메일과 역할을 검증하고 `runtimeConfig`의 시크릿 키로 폼 백엔드에 전달합니다. 라우트는 `$fetch`에는 JSON으로, 일반 폼 POST에는 `303` 리디렉션으로 응답하므로 페이지가 하이드레이션되기 전에도 가입이 작동합니다.

[메인 가이드](https://ikiform.com/ko/guides/waitlist.md#form)에는 서버 라우트, `runtimeConfig`, `$fetch`로 POST하는 컴포넌트로 된 Nuxt 탭이 있습니다. 그 폼은 JavaScript가 필요하고, 이메일만 보내고, 요청마다 페이지를 제공합니다. 이 버전은 랜딩 페이지를 프리렌더링하고, 분류용 질문을 하나 묻고, 번들이 로드되지 않아도 계속 작동합니다.

## Nuxt에서 다른 점

Nuxt는 기본적으로 서버에서 렌더링하고, Nitro가 페이지 옆의 `server/` 폴더를 제공합니다. 대기자 명단에는 세 가지 세부 사항이 영향을 줍니다.

| 세부 사항 | 대기자 명단에 미치는 영향 |
| --- | --- |
| `prerender: true`를 지정한 `routeRules` | 랜딩 페이지는 HTML로 한 번 빌드됨. 서버 라우트는 여전히 요청마다 실행됨 |
| `readBody`는 urlencoded 본문도 파싱 | 일반 `<form>`이 `$fetch`와 같은 라우트로 POST할 수 있고, 다른 사람 사이트의 폼도 마찬가지 |
| 프리렌더링된 페이지에는 빌드 시점에 쿼리 문자열이 없음 | `?ref=`와 `?problem=`은 렌더링 중이 아니라 페이지가 마운트된 뒤에 읽어야 함 |

두 번째 행은 양날의 검입니다. JavaScript 없는 경로가 작동하는 이유이기도 하고, 라우트가 무엇보다 먼저 `Sec-Fetch-Site` 헤더를 검사하는 이유이기도 합니다. 그러지 않으면 다른 사이트의 폼이 여러분의 키로 가입을 POST할 수 있습니다.

다이어그램: Nitro 라우트 하나가 $fetch에는 JSON으로, 일반 POST에는 리디렉션으로 응답

1. 랜딩 페이지는 정적 HTML입니다. 마운트된 뒤 컴포넌트가 ?ref= 또는 ?utm_source=를 유입 경로 값에 복사합니다.
2. 폼이 서버 라우트로 POST합니다. Vue가 하이드레이션된 뒤에는 $fetch로 JSON을, 그 전에는 일반 POST로 urlencoded를 보냅니다.
3. 라우트가 교차 사이트 POST를 거부하고, Zod로 본문을 검증하고, 시크릿 키로 가입을 Ikiform에 전달합니다.
4. JSON POST는 { joined: true } 또는 오류를 받고, 컴포넌트가 그 메시지를 폼 아래에 보여 줍니다.
5. 일반 POST는 /thanks로 가는 303을 받거나, 문제 코드와 함께 /로 돌아가며 페이지가 마운트된 뒤 그 코드를 메시지로 바꿉니다.

POST의 콘텐츠 타입이 응답을 결정합니다. 브라우저가 도착하는 두 페이지 모두 프리렌더링되어 있습니다.

## 프로젝트 설정하기

[Nuxt 문서 가이드](https://ikiform.com/ko/docs/guides/nuxt.md)처럼 폼과 시크릿 키를 만드세요. Nuxt 4의 `app/` 구조 기준으로, 이 페이지의 파일은 다음과 같습니다.

`프로젝트`:

```text
app/
  pages/index.vue
  pages/thanks.vue
  components/WaitlistForm.vue
server/
  api/waitlist.post.ts
nuxt.config.ts
.env
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
  routeRules: {
    "/": { prerender: true },
    "/thanks": { prerender: true },
  },
});
```

`runtimeConfig`의 빈 `ikiformSecretKey`는 런타임에 `NUXT_IKIFORM_SECRET_KEY`로 채워집니다. `public` 밖의 키는 서버에만 있습니다. `npm install zod`로 Zod를 추가하세요.

`.env`:

```bash
NUXT_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`, `role`, `ref`를 보냅니다. 폼의 **Fields** 탭에 필드 정의를 추가한다면 세 개 모두를 select와 같은 역할 값으로 넣으세요.

`필드`:

```json
{
  "version": 1,
  "title": "Waitlist",
  "fields": [
    { "id": "email", "type": "email", "label": "Email", "required": true, "placeholder": "you@company.com" },
    { "id": "role", "type": "select", "label": "What do you do?", "options": [{ "value": "founder", "label": "Founder" }, { "value": "engineer", "label": "Engineer" }, { "value": "designer", "label": "Designer" }, { "value": "other", "label": "Something else" }] },
    { "id": "ref", "type": "text", "label": "Source", "validation": { "max_length": 40 } }
  ]
}
```

## 만들기

### 서버 라우트

`server/api/waitlist.post.ts`:

```ts
import { z } from "zod";

const Signup = z.object({
  email: z.email(),
  role: z.enum(["founder", "engineer", "designer", "other"]).optional().catch(undefined),
  ref: z.string().regex(/^[a-z0-9_-]{1,40}$/).catch("direct"),
  _gotcha: z.string().catch(""),
});

const MESSAGES = {
  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.",
};

type Problem = keyof typeof MESSAGES;

export default defineEventHandler(async (event) => {
  if (getRequestHeader(event, "sec-fetch-site") === "cross-site") {
    throw createError({ status: 403, message: "Posts from other sites aren't accepted." });
  }
  const plain = !getRequestHeader(event, "content-type")?.includes("application/json");

  const answer = (problem: Problem | null) => {
    if (plain) return sendRedirect(event, problem ? `/?problem=${problem}` : "/thanks", 303);
    if (problem) throw createError({ status: problem === "busy" ? 429 : 400, message: MESSAGES[problem] });
    return { joined: true };
  };

  const parsed = Signup.safeParse(await readBody(event));
  if (!parsed.success) return answer("email");

  const config = 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 ${config.ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: JSON.stringify(parsed.data),
    signal: AbortSignal.timeout(8000),
  }).catch(() => null);

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

- `.post.ts` 접미사는 라우트를 `POST`로 제한합니다. 다른 메서드는 `405`를 받습니다.
- `Sec-Fetch-Site`는 내 페이지에서는 `same-origin`, 다른 곳의 폼에서는 `cross-site`입니다. 브라우저가 설정하며 페이지 스크립트는 바꿀 수 없습니다.
- Zod의 `.catch()`는 잘못된 `role`이나 `ref`를 가입 실패로 만들지 않고 안전한 값으로 바꿉니다. 보여 줄 가치가 있는 오류는 잘못된 이메일뿐입니다.
- Ikiform은 브라우저의 `Origin` 헤더로 무료 `localhost` 테스트 제출을 세는데 라우트 자체의 `fetch`는 이 헤더를 보내지 않으므로, 이를 전달합니다.
- `useRuntimeConfig(event)`는 요청마다 키를 읽으므로, 빌드 후 호스트에 설정한 값도 반영됩니다.
- 리디렉션은 메시지가 아니라 짧은 문제 코드를 전달하므로, 조작된 링크가 페이지에 문구를 쓸 수 없습니다.

### 폼 컴포넌트

`app/components/WaitlistForm.vue`:

```vue
<script setup lang="ts">
const MESSAGES: Record<string, 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.",
};

const route = useRoute();
const email = ref("");
const role = ref("");
const source = ref("direct");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");

onMounted(() => {
  const tag = String(route.query.ref ?? route.query.utm_source ?? "").toLowerCase().slice(0, 40);
  if (tag) source.value = tag;
  const problem = String(route.query.problem ?? "");
  if (MESSAGES[problem]) error.value = MESSAGES[problem];
});

async function join() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/waitlist", {
      method: "POST",
      body: { email: email.value, role: role.value || undefined, ref: source.value, _gotcha: trap.value },
    });
    status.value = "joined";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't join the list. Try again.";
    status.value = "idle";
  }
}
</script>

<template>
  <p v-if="status === 'joined'" role="status">You're on the list. We'll email you on launch day.</p>
  <form v-else method="post" action="/api/waitlist" @submit.prevent="join">
    <label for="email">Email</label>
    <input id="email" v-model="email" name="email" type="email" autocomplete="email" required />
    <label for="role">What do you do?</label>
    <select id="role" v-model="role" name="role">
      <option value="">Choose one</option>
      <option value="founder">Founder</option>
      <option value="engineer">Engineer</option>
      <option value="designer">Designer</option>
      <option value="other">Something else</option>
    </select>
    <input type="hidden" name="ref" :value="source" />
    <input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">{{ error }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
  </form>
</template>
```

- 폼은 `method`와 `action`을 유지합니다. 하이드레이션 전에는 브라우저가 urlencoded로 POST하고, 그 후에는 `@submit.prevent`가 이를 JSON을 보내는 `$fetch`에 넘깁니다.
- `onMounted`가 쿼리를 읽습니다. 렌더링 중에 읽으면 빌드 시점 HTML에는 빈 값이, 브라우저에는 다른 값이 들어가 하이드레이션 불일치가 생깁니다.
- 빈 역할은 `undefined`로 보내지며, `$fetch`는 이를 JSON 본문에서 뺍니다.
- `$fetch` 오류는 `data.message`에 라우트의 메시지를 담고 있으므로, 컴포넌트가 서버의 문구를 그대로 보여 줍니다.

`app/pages/index.vue`에서 `<WaitlistForm />`을 쓰고, 감사 문구가 있는 `app/pages/thanks.vue`를 작성하세요. 허니팟은 손대지 않고 전달되므로 이를 채운 봇은 스팸으로 저장됩니다. 시크릿 키를 쓴 POST는 Ikiform의 사람 확인을 건너뛰므로 **Spam check**를 켜도 여기서는 아무 효과가 없습니다.

## 배포 참고 사항

`nuxt build`로 빌드하세요. `nuxt generate`는 모든 페이지를 서버 없는 정적 사이트로 프리렌더링하므로 `/api/waitlist`가 실행되지 않습니다. 위의 `routeRules`를 쓰면 빌드 한 번으로 정적 페이지와 살아 있는 라우트를 함께 얻습니다.

| 호스트 | 설정 | 시크릿 |
| --- | --- | --- |
| Vercel | 설정 불필요. Vercel이 가져올 때 Nitro를 감지 | **Environment Variables**에 `NUXT_IKIFORM_SECRET_KEY` |
| Netlify | 설정 불필요. Nuxt가 Netlify 빌드를 감지 | **Environment variables**에 `NUXT_IKIFORM_SECRET_KEY` |
| Cloudflare Pages | Git 연동 시 설정 불필요. 직접 업로드한다면 `--preset=cloudflare_pages`로 빌드 | 프로젝트 설정에서 암호화된 변수로 `NUXT_IKIFORM_SECRET_KEY` |

## 작동 확인하기

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

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| 프로덕션에서 `/api/waitlist`가 `404` | 서버를 포함하지 않는 `nuxt generate`로 사이트를 빌드했습니다. `nuxt build`로 빌드하고 `routeRules`에 `prerender`를 유지하세요. |
| 숨겨진 `ref` 입력에서 하이드레이션 불일치 경고 | 렌더링 중에 쿼리를 읽고 있습니다. 위처럼 `onMounted`에서 읽으세요. 프리렌더링된 HTML에는 쿼리 문자열이 없습니다. |
| 모든 POST가 `failed`로 응답함 | 키가 비어 있습니다. `runtimeConfig.ikiformSecretKey`를 채우려면 변수 이름이 `NUXT_IKIFORM_SECRET_KEY`여야 합니다. |
| `role`이나 `ref`에 대해 `unknown_field`와 함께 `422` | 필드 정의에 없습니다. 위의 정의처럼 둘 다 추가하세요. |
| `402 plan_required` | 워크스페이스에 플랜이 없는데 POST가 실제 사이트에서 왔습니다. 잃어버린 것이 아니라 보류된 것입니다. **Settings › Plan and usage**에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |

## 질문

### Nuxt 폼이 JavaScript 없이 작동할 수 있나요?

네. 폼에 서버 라우트를 가리키는 method와 action을 주세요. Nitro의 readBody가 urlencoded POST를 파싱하고 라우트가 리디렉션으로 응답할 수 있으며, 하이드레이션된 페이지에서 $fetch로 보낸 POST는 JSON을 받습니다.

### 대기자 명단 폼이 있는 Nuxt 페이지를 프리렌더링할 수 있나요?

네. routeRules로 페이지를 프리렌더링하고 폼의 서버 라우트는 살려 둔 채 nuxt build로 빌드하세요. ref 같은 쿼리 값은 페이지가 마운트된 뒤에 읽으세요.

### Nuxt에서는 시크릿 키를 어디에 두나요?

호스트의 NUXT_ 환경 변수로 채워지는 비공개 runtimeConfig 키에 두세요. 비공개 키는 서버에서 useRuntimeConfig로만 읽을 수 있습니다.

### 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, 낙관적 감사 문구, 방문자별 한도를 다룹니다.
- [SvelteKit 대기자 명단](https://ikiform.com/ko/guides/waitlist/sveltekit.md): SvelteKit 3 대기자 명단: 훅이 첫 ?ref=를 쿠키에 보관하고, 이름 있는 폼 액션이 가입을 전달하며, 재방문자는 이미 가입했음을 확인합니다.
- [선별 기능이 있는 베타 신청](https://ikiform.com/ko/guides/waitlist/beta-signup.md): 서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.
- [Product Hunt 출시 리스트](https://ikiform.com/ko/guides/waitlist/product-hunt.md): Product Hunt 몇 주 전부터 출시 리스트를 모으고, 가입마다 채널 태그를 달고, 태평양 시간 리셋에 맞춰 출시일 이메일을 두 번에 나눠 보냅니다.
- [모바일 앱 대기자 명단](https://ikiform.com/ko/guides/waitlist/mobile-app.md): iOS나 Android 앱의 출시 전 대기자 명단으로, 사용하는 휴대폰을 묻고 iPhone 테스터는 TestFlight로 보내고 Play 비공개 테스트를 채웁니다.
