새 SaaS용 대기자 명단5분 분량
Nuxt로 대기자 명단 만들기
프리렌더링된 랜딩 페이지의 Nuxt 대기자 명단: Nitro 라우트 하나가 $fetch와 일반 폼 POST를 받아 Zod로 검사하고, JavaScript 없이 리디렉션합니다.
routeRules로 랜딩 페이지를 프리렌더링하고, 폼을 Nitro 서버 라우트로 POST하세요. 라우트는 Zod로 이메일과 역할을 검증하고 runtimeConfig의 시크릿 키로 폼 백엔드에 전달합니다. 라우트는 $fetch에는 JSON으로, 일반 폼 POST에는 303 리디렉션으로 응답하므로 페이지가 하이드레이션되기 전에도 가입이 작동합니다.
메인 가이드에는 서버 라우트, 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할 수 있습니다.
- 랜딩 페이지는 정적 HTML입니다. 마운트된 뒤 컴포넌트가 ?ref= 또는 ?utm_source=를 유입 경로 값에 복사합니다.
- 폼이 서버 라우트로 POST합니다. Vue가 하이드레이션된 뒤에는 $fetch로 JSON을, 그 전에는 일반 POST로 urlencoded를 보냅니다.
- 라우트가 교차 사이트 POST를 거부하고, Zod로 본문을 검증하고, 시크릿 키로 가입을 Ikiform에 전달합니다.
- JSON POST는 { joined: true } 또는 오류를 받고, 컴포넌트가 그 메시지를 폼 아래에 보여 줍니다.
- 일반 POST는 /thanks로 가는 303을 받거나, 문제 코드와 함께 /로 돌아가며 페이지가 마운트된 뒤 그 코드를 메시지로 바꿉니다.
프로젝트 설정하기
Nuxt 문서 가이드처럼 폼과 시크릿 키를 만드세요. Nuxt 4의 app/ 구조 기준으로, 이 페이지의 파일은 다음과 같습니다.
app/
pages/index.vue
pages/thanks.vue
components/WaitlistForm.vue
server/
api/waitlist.post.ts
nuxt.config.ts
.envexport default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
routeRules: {
"/": { prerender: true },
"/thanks": { prerender: true },
},
});runtimeConfig의 빈 ikiformSecretKey는 런타임에 NUXT_IKIFORM_SECRET_KEY로 채워집니다. public 밖의 키는 서버에만 있습니다. npm install zod로 Zod를 추가하세요.
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_keyIKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.이 폼은 email, role, ref를 보냅니다. 폼의 Fields 탭에 필드 정의를 추가한다면 세 개 모두를 select와 같은 역할 값으로 넣으세요.
{
"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 } }
]
}만들기
서버 라우트
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)는 요청마다 키를 읽으므로, 빌드 후 호스트에 설정한 값도 반영됩니다.- 리디렉션은 메시지가 아니라 짧은 문제 코드를 전달하므로, 조작된 링크가 페이지에 문구를 쓸 수 없습니다.
폼 컴포넌트
<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 |
작동 확인하기
nuxt dev를 실행하고http://localhost:3000/?ref=test를 열어 역할을 고르고 가입하세요. 감사 문구가 폼을 대신합니다.- 대시보드에서 Submissions를 여세요. 가입에
ref가test로, 역할이 고른 값으로 설정되어 있습니다. - JavaScript를 끄고 역할 없이 가입하세요.
/thanks로 이동하고, 제출에는role이 없습니다. - JavaScript를 끈 상태로
a@b를 제출하세요./?problem=email로 이동하고, JavaScript를 다시 켜고 새로 고치면 메시지가 표시됩니다. - 허니팟을 채워서
_gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. nuxt dev에서 보낸 POST는localhost출처를 가지므로 테스트 제출로 집계됩니다. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도localhost에서 보낸 테스트 제출을 매달 50건 받습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 내세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
주의할 점
| 문제 | 해결 방법 |
|---|---|
프로덕션에서 /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에서 플랜을 고르거나 팩을 구매하면 보류된 제출이 들어옵니다. |
질문
네. 폼에 서버 라우트를 가리키는 method와 action을 주세요. Nitro의 readBody가 urlencoded POST를 파싱하고 라우트가 리디렉션으로 응답할 수 있으며, 하이드레이션된 페이지에서 $fetch로 보낸 POST는 JSON을 받습니다.
네. routeRules로 페이지를 프리렌더링하고 폼의 서버 라우트는 살려 둔 채 nuxt build로 빌드하세요. ref 같은 쿼리 값은 페이지가 마운트된 뒤에 읽으세요.
호스트의 NUXT_ 환경 변수로 채워지는 비공개 runtimeConfig 키에 두세요. 비공개 키는 서버에서 useRuntimeConfig로만 읽을 수 있습니다.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.