가이드12분 분량
새 SaaS용 대기자 명단 만드는 방법
내 랜딩 페이지에 출시 전 대기자 명단을 만듭니다. 폼, 스팸 방지, 중복 처리, 확인 이메일, 출시일의 깔끔한 내보내기까지 다룹니다.
랜딩 페이지에 일반 HTML 폼을 두고 방문자의 이메일을 폼 백엔드로 POST하면, 출시 전에 서버를 운영할 필요가 없습니다. 각 가입은 웹훅으로 출시 때 쓸 이메일 도구에 전달하고, 확인 이메일은 그 도구가 보내게 하세요. 출시일에는 목록을 CSV로 내보내고, 이메일 기준으로 중복을 지운 뒤, 출시 이메일을 한 번 보내면 됩니다.
대기자 명단은 SaaS 아이디어를 가장 저렴하게 검증하는 방법입니다. 아직 존재하지도 않는 제품에 낯선 사람들이 이메일을 남긴다면 만들 이유가 생기고, 출시 때 알릴 사람들의 목록도 생깁니다. 이 가이드는 데이터베이스나 서버 없이 내 랜딩 페이지에 대기자 명단을 만들고 출시일에 대비합니다.
대기자 명단의 작동 방식
모든 대기자 명단은 같은 세 부분으로 이루어집니다. 페이지에 있는 폼, 각 가입을 저장하는 곳, 그리고 목록에 이메일을 보낼 도구입니다. 여기서는 폼이 Ikiform으로 POST합니다. Ikiform은 POST의 스팸 여부를 검사하고, 저장하고, 여러분에게 이메일을 보내고, 서명된 웹훅을 보냅니다. 작은 릴레이나 자동화 도구가 주소를 이메일 도구로 넘겨줍니다.
- 방문자가 랜딩 페이지에 이메일을 입력하면 폼이 Ikiform 엔드포인트로 POST합니다.
- Ikiform이 허니팟, 스팸 점수, 레이트 리밋을 검사한 뒤 가입을 저장합니다.
- 가입이 대시보드에 표시되고, 거기서 검색하거나 목록을 CSV로 내보낼 수 있습니다.
- Ikiform은 여러분과 팀에게 이메일을 보냅니다. 가입한 사람에게는 절대 이메일을 보내지 않습니다.
- Ikiform이 서명된 submission.created 웹훅을 보냅니다.
- 릴레이나 자동화 도구가 주소를 이메일 도구에 추가하고, 이메일 도구가 확인 이메일과 나중에 출시 이메일을 보냅니다.
대기자 명단을 운영하는 네 가지 방법
정답인 도구는 하나가 아닙니다. 아래 표는 흔한 네 가지 구성을 각각의 강점과 나중에 치르게 되는 비용으로 비교합니다.
| 방식 | 강점 | 단점 |
|---|---|---|
| LaunchList나 Waitlister 같은 대기자 명단 SaaS | 추천 링크, 리더보드, 대기 순번을 바로 쓸 수 있습니다. 아직 사이트가 없다면 호스팅 페이지도 있습니다. | 목록이 다른 제품 안에 있습니다. 돈을 내거나 스타일을 바꾸지 않으면 위젯이 그 제품처럼 보입니다. 가격은 보통 가입 수에 따라 올라갑니다. |
| Typeform이나 Tally 같은 폼 빌더 임베드 | 코드 없이 다듬어진 한 질문씩 흐름을 만들 수 있습니다. | 페이지와 어울리지 않고 자체 스크립트를 불러오는 iframe입니다. 브랜딩, 응답 한도, 웹훅은 플랜에 따라 다릅니다. |
| 자체 데이터베이스와 API 라우트 | 데이터와 추천 코드 같은 모든 로직을 완전히 제어할 수 있습니다. | 스팸 방지, 레이트 리밋, 알림, 내보내기를 직접 만들어야 하고, 랜딩 페이지가 정적이어도 서버가 필요합니다. |
| Ikiform 같은 폼 백엔드 | 어떤 사이트에서든 내 HTML을 그대로 씁니다. 서버 없이 스팸 필터링, 저장, 이메일 알림, 서명된 웹훅, CSV 내보내기를 제공합니다. | 추천 리더보드가 없고 가입한 사람에게 이메일을 보내지 않습니다. 그런 기능은 이메일 도구가 맡습니다. |
추천 기능이 계획의 전부라면 대기자 명단 SaaS를 고르세요. 폼이 사이트의 나머지 부분과 같은 모습이길 원하고, 목록을 자유롭게 옮길 수 있어야 하고, 어차피 출시 후에 쓸 도구로 이메일을 보내고 싶다면 폼 백엔드를 고르세요. 이 가이드의 나머지 부분은 폼 백엔드를 사용합니다.
폼
처음에는 이메일만 물어보세요. 필드가 하나 늘 때마다 가입이 줄어들고, 질문은 이미 가입한 사람에게 나중에 이메일로 하면 됩니다. 아래에서 스택을 고르세요. 선택은 이 가이드의 모든 코드 예제에 적용됩니다.
가입 폼이 나타날 위치에 폼을 붙여 넣으세요. 일반적인 방식으로 POST하고 리디렉션하므로 JavaScript가 전혀 없어도 작동합니다.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="email">Email</label>
<input id="email" name="email" type="email" placeholder="you@company.com" autocomplete="email" required />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Join the waitlist</button>
</form>방문자를 페이지에 머무르게 하려면 폼 뒤에 이 스크립트를 추가하세요. fetch로 POST하고, 폼을 감사 문구로 바꾸며, 서버가 돌려준 필드 오류를 보여 줍니다.
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);
const labels = {
email: "Email",
};
form.addEventListener("submit", async (event) => {
event.preventDefault();
const button = form.querySelector("button");
button.disabled = true;
status.textContent = "";
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.replaceWith("You're on the list. We'll email you at launch.");
return;
}
const fields = Object.entries(result.error.fields ?? {});
status.textContent = fields.length
? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
: result.error.message;
} catch {
status.textContent = "Couldn't reach the server. Try again.";
}
button.disabled = false;
});HTML에서 더 자세히 알아보려면 어떤 랜딩 페이지에든 일반 HTML로 대기자 명단 폼 추가하는 방법을(를) 읽어 보세요.
서버 액션이 시크릿 키로 가입을 전달하고, useActionState가 페이지를 새로 고치지 않고 결과를 보여 줍니다. JavaScript가 아직 로드되지 않았어도 폼은 POST됩니다.
"use server";
import { headers } from "next/headers";
export interface JoinState {
ok: boolean;
message: string;
email?: string;
}
export async function joinWaitlist(_previous: JoinState, formData: FormData): Promise<JoinState> {
const email = String(formData.get("email") ?? "").trim();
if (!email.includes("@")) return { ok: false, message: "Enter the email we should send your invite to.", email };
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: (await headers()).get("origin") ?? "",
},
body: JSON.stringify({
email,
ref: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
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." };
return {
ok: false,
message: result?.error?.fields?.email?.[0] ?? result?.error?.message ?? "Couldn't join the list. Try again.",
email,
};
}"use client";
import { useActionState } from "react";
import { type JoinState, joinWaitlist } from "./actions";
const initial: JoinState = { ok: false, message: "" };
export function WaitlistForm() {
const [state, action, pending] = useActionState(joinWaitlist, initial);
if (state.ok) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required defaultValue={state.email} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}Next.js에서 더 자세히 알아보려면 서버 액션으로 Next.js 대기자 명단 만드는 방법을(를) 읽어 보세요.
accept: "form"을 지정한 Astro Action이 Zod로 이메일을 검증하고 전달합니다. 폼이 클라이언트 JavaScript 없이 POST하므로 페이지는 요청 시 렌더링되어야 합니다. 어댑터를 추가하고 prerender = false를 유지하세요.
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
joinWaitlist: defineAction({
accept: "form",
input: z.object({
email: z.email(),
ref: z.string().default("homepage"),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
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(input),
});
const result = await response.json();
if (!result.ok) {
throw new ActionError({
code: "BAD_REQUEST",
message: result.error.fields?.email?.[0] ?? result.error.message,
});
}
return { joined: true };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.joinWaitlist);
---
{result?.data?.joined ? (
<p role="status">You're on the list. We'll email you on launch day.</p>
) : (
<form method="POST" action={actions.joinWaitlist}>
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && (
<p role="alert">{isInputError(result.error) ? "Enter a valid email address." : result.error.message}</p>
)}
<button>Join the waitlist</button>
</form>
)}Astro에서 더 자세히 알아보려면 Astro로 대기자 명단 만들기을(를) 읽어 보세요.
createServerFn POST 함수가 FormData를 받아 .validator()에서 검사하고 서버에서 전달합니다. 라우트는 useServerFn을 통해 이 함수를 호출합니다.
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 joinWaitlist = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) throw new Error("Enter the email we should send your invite to.");
return {
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }) => {
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(data),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return { ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) };
});
export const Route = createFileRoute("/")({ component: Home });
function Home() {
const join = useServerFn(joinWaitlist);
const [state, setState] = useState<{ ok: boolean; message: string } | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setState(await join({ data }));
} catch (error) {
setState({ ok: false, message: error instanceof Error ? error.message : "Couldn't join the list. Try again." });
} finally {
setPending(false);
}
}
if (state?.ok) return <p role="status">{state.message}</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state?.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
);
}TanStack Start에서 더 자세히 알아보려면 TanStack Start로 대기자 명단 만들기을(를) 읽어 보세요.
라우트의 action이 POST를 전달하고, actionData가 결과를 컴포넌트로 돌려줍니다. data()는 오류 시 Ikiform의 상태 코드를 유지합니다.
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/home";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
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: String(formData.get("ref") ?? "homepage"),
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { ok: true, message: "You're on the list. We'll email you on launch day." };
return data(
{ ok: false, message: String(result.error.fields?.email?.[0] ?? result.error.message) },
{ status: response.status },
);
}
export default function Home({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.ok) return <p role="status">{actionData.message}</p>;
return (
<Form method="post">
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData?.message ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</Form>
);
}React Router에서 더 자세히 알아보려면 React Router로 대기자 명단 만들기을(를) 읽어 보세요.
기본 폼 액션이 POST를 전달하고, 오류가 나면 메시지와 함께 fail()을 반환합니다. use:enhance는 새로 고침 없이 제출하고 입력한 이메일을 입력란에 그대로 둡니다. SvelteKit 3에서는 $app/env/private가 읽을 수 있도록 src/env.ts에서 defineEnvVars로 IKIFORM_SECRET_KEY를 선언하세요.
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 data = await request.formData();
const email = String(data.get("email") ?? "").trim();
if (!email.includes("@")) return fail(400, { email, message: "Enter the email we should send your invite to." });
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({
email,
ref: String(data.get("ref") ?? "homepage"),
_gotcha: String(data.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (!result.ok) {
return fail(response.status, {
email,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
</script>
{#if form?.joined}
<p role="status">You're on the list. We'll email you on launch day.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update();
pending = false;
};
}}
>
<label for="email">Email</label>
<input id="email" name="email" type="email" required value={form?.email ?? ""} placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message}
<p role="alert">{form.message}</p>
{/if}
<button disabled={pending}>{pending ? "Joining" : "Join the waitlist"}</button>
</form>
{/if}SvelteKit에서 더 자세히 알아보려면 SvelteKit으로 대기자 명단 만들기을(를) 읽어 보세요.
서버 라우트가 시크릿 키를 브라우저 밖에 둡니다. 환경 변수에 NUXT_IKIFORM_SECRET_KEY를 설정하면 Nuxt가 이 값으로 runtimeConfig.ikiformSecretKey를 채웁니다.
export default defineEventHandler(async (event) => {
const body = await readBody<{ email?: string; ref?: string; _gotcha?: string }>(event);
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({
email: body.email?.trim() ?? "",
ref: body.ref ?? "homepage",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.fields?.email?.[0] ?? result.error.message),
});
}
return { joined: true };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const email = ref("");
const trap = ref("");
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join() {
status.value = "sending";
error.value = "";
try {
await $fetch("/api/waitlist", {
method: "POST",
body: { email: email.value, ref: "homepage", _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 @submit.prevent="join">
<label for="email">Email</label>
<input id="email" v-model="email" name="email" type="email" required placeholder="you@company.com" />
<input v-model="trap" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>Nuxt에서 더 자세히 알아보려면 Nuxt로 대기자 명단 만들기을(를) 읽어 보세요.
싱글 페이지 앱에는 자체 서버가 없으므로 폼이 브라우저에서 Ikiform으로 바로 POST합니다. 이 코드에 시크릿 키를 절대 넣지 마세요. 대신 Allowed domains(허용 도메인)가 폼을 보호합니다.
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function WaitlistForm() {
const [status, setStatus] = useState<"idle" | "sending" | "joined">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setStatus("sending");
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setStatus("joined");
return;
}
setError(result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
}
setStatus("idle");
}
if (status === "joined") return <p role="status">You're on the list. We'll email you on launch day.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Joining" : "Join the waitlist"}</button>
</form>
);
}같은 브라우저 측 POST를 Vue 싱글 파일 컴포넌트로 작성한 것입니다. 폼이 action을 유지하므로 스크립트가 로드되지 않아도 작동합니다.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const status = ref<"idle" | "sending" | "joined">("idle");
const error = ref("");
async function join(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
status.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
status.value = "joined";
return;
}
error.value = result.error.fields?.email?.[0] ?? result.error.message;
} catch {
error.value = "Couldn't reach the server. 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 :action="ENDPOINT" method="post" @submit.prevent="join">
<label for="email">Email</label>
<input id="email" name="email" type="email" required placeholder="you@company.com" />
<input type="hidden" name="ref" value="homepage" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="status === 'sending'">{{ status === "sending" ? "Joining" : "Join the waitlist" }}</button>
</form>
</template>- 엔드포인트는 폼의 URL입니다.
frm_xxxxxxxxxxxxxxxx를 대시보드의 ID로 바꾸세요. name="email"은 필드 ID입니다.email그대로 두세요. Ikiform은 이 값을 알림 이메일의 회신 주소로 쓰고, 일회용 이메일 도메인인지 검사합니다.- 숨겨진
_gotcha입력은 허니팟입니다. 사람에게는 보이지 않습니다. 모든 입력을 채우는 봇은 스스로 스팸임을 드러내며, 서버 버전은 이 값을 손대지 않고 그대로 전달합니다. - 숨겨진
ref입력은 가입이 어디서 왔는지 기록합니다. 페이지나 캠페인마다 값을 바꾸세요. - Fields 탭에 필드 정의가 있으면, 정의에 없는 입력은
422와unknown_field오류로 거부됩니다. 그러니 아래처럼email과 함께ref도 등록하세요. - 일반 HTML POST는 성공 후 브라우저를 리디렉션합니다. 폼 설정의 Redirect after submit에 내 감사 페이지를 지정하지 않으면 Ikiform의 호스팅 감사 페이지가 표시됩니다.
{
"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 } }
]
}IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.서버 버전은 브라우저의 Origin 헤더를 전달합니다. Ikiform은 이 헤더로 무료 localhost 테스트를 구분하는데, 서버 자체의 fetch는 이 헤더를 보내지 않기 때문입니다.
문서의 프레임워크 가이드에서 더 자세히 다룹니다: Next.js, Astro, SvelteKit, Nuxt, React, Vue, 일반 HTML. SDK는 브라우저 요청을 submit()으로 감싸고, 대기자 명단 폼 템플릿은 필드 네 개짜리 버전을 만들어 줍니다.
물어볼 만한 필드
필요한 필드는 이메일 하나뿐입니다. 출시 전에 목록을 분류하고 싶다면 최대 두 개까지만 더하세요. 아래 필드 네 개짜리 버전을 써 보세요. 브라우저에서 Ikiform의 검증기를 실행해 저장될 내용이나 돌아올 422를 보여 줍니다.
미리보기
Ikiform의 검증기를 브라우저에서 실행합니다. 아무것도 전송되지 않습니다.
ref를 더한 것입니다. 이 필드는 보이는 입력란이 아니라 페이지가 숨겨진 입력으로 채웁니다. 폼의 Fields 탭에 붙여 넣으면 Ikiform이 서버에서 이를 적용합니다.| 필드 | 묻는 이유 | 유의할 점 |
|---|---|---|
email | 명단 그 자체 | 필수. 휴대폰에서 알맞은 키보드가 나오도록 type="email"을 쓰세요 |
name | 개인화된 출시 이메일 | 선택. 이름만으로 충분합니다 |
role | 창업자를 먼저 초대하거나, 엔지니어에게는 다른 이메일을 쓸 수 있습니다 | select를 쓰면 필터링하기 좋게 답이 깔끔하게 유지됩니다 |
source | 돈을 쓰기 전에 어떤 채널이 효과적인지 알 수 있습니다 | 자유 입력은 솔직하지만 지저분합니다. 숨겨진 ref 입력이 더 깔끔합니다. 아래를 참고하세요 |
ref | 묻지 않고도 페이지나 캠페인별 가입 수를 셉니다 | 숨겨진 입력. max_length 40인 선택 text |
묻지 않고 가입 경로를 추적하려면 페이지나 캠페인마다 숨겨진 입력을 추가하세요. _로 시작하는 이름은 Ikiform에 대한 지시어라 저장되지 않으므로 ref 같은 일반 이름을 쓰세요.
<input type="hidden" name="ref" value="homepage" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />각 제출에는 페이지의 출처와 리퍼러도 기록되므로, ref가 없어도 가입이 어느 페이지에서 왔는지 알 수 있습니다. _redirect 입력은 브라우저를 내 감사 페이지로 보냅니다. 폼의 Allowed domains 중 하나에 있는 https: URL일 때만 작동합니다.
true를 저장하므로 그 사람이 언제 동의했는지 기록됩니다. 뉴스레터 템플릿에 이런 체크박스가 있습니다.명단에서 봇 막기
이메일 필드가 있는 공개 폼에는 며칠 안에 봇이 몰립니다. 가짜 가입은 숫자를 부풀리고, 더 나쁘게는 출시 이메일이 스팸 트랩에 걸려 발신자 평판을 떨어뜨립니다. Ikiform은 네 겹의 방어를 제공하며, 처음 두 가지는 설정 없이 켜져 있습니다.
| 방어 | 막는 것 | 설정 |
|---|---|---|
| 허니팟 | 모든 입력을 채우는 단순한 봇 | 위 폼의 숨겨진 _gotcha 입력 |
| 스팸 점수 | 일회용 주소(+2), 동일한 반복(+3), 링크와 알려진 스팸 문구. 5점이 되면 가입이 스팸으로 저장됩니다 | 없음 |
| 레이트 리밋 | 반복해서 POST하는 스크립트 | 기본으로 켜짐: Standard, 방문자당 분당 5회, 폼 전체 분당 300회 |
| 사람 확인 | 실제 브라우저를 실행하는 봇 | Spam check를 Ikiform으로 설정하세요. 키 관리 없이 Cloudflare Turnstile을 실행합니다 |
스팸은 일반 성공 응답과 함께 저장되므로 봇은 아무것도 알 수 없습니다. 스팸은 이메일이나 웹훅을 보내지 않고 사용량에도 포함되지 않습니다. 첫 달에는 일주일에 한 번 대시보드의 Spam 필터를 확인하세요. 실제 가입이 그곳에 있다면 스팸 아님으로 표시하면 그때 웹훅이 전송됩니다.
Allowed domains에도 도메인을 추가하세요. 그러면 다른 사이트에서 온 POST는 스팸으로 표시되거나, Reject를 고른 경우 403 origin_not_allowed로 거부됩니다. 사람 확인을 켜도 HTML 폼은 바꿀 필요가 없습니다. fetch 버전은 먼저 @ikiform/sdk의 challenge()로 토큰을 요청해야 하며, 스팸 문서에 그 네 줄이 나와 있습니다.
같은 사람이 두 번
사람들은 다른 탭이나 다른 기기에서, 또는 잊어버리고 일주일 뒤에 다시 가입합니다. Ikiform은 받아들인 POST를 모두 저장하며, 이메일 기준으로 반복을 합치거나 거부하지 않습니다. 반복 가입은 이렇게 처리됩니다.
| 경우 | 결과 |
|---|---|
| 같은 이메일, 다른 필드 또는 24시간 이후 | 두 번째 일반 제출 |
| 24시간 안에 같은 필드로 다시 | 스팸 점수 +3으로 5 미만이므로 두 번째 일반 제출 |
| 일회용 이메일 도메인에서 온 동일한 반복 | +3과 +2로 5에 도달해 스팸으로 저장 |
그러니 중복은 어느 정도 생긴다고 보고, 목록이 최종적으로 모이는 곳에서 지우세요. 세 곳에서 할 수 있으며, 수고가 적은 순서대로 정리하면 다음과 같습니다.
- 이메일 도구에서. 대부분의 이메일 도구는 주소를 기준으로 연락처를 관리하므로, 같은 주소를 두 번 추가하면 연락처가 둘이 되지 않고 하나가 업데이트됩니다.
- 내보낼 때. 어딘가로 가져오기 전에 CSV에서 중복 이메일을 지우세요. 아래에 두 가지 방법이 있습니다.
- 릴레이에서. 주소를 소문자로 바꾸고 공백을 제거한 뒤 유니크 제약 조건이나 upsert로 넣으면, 반복 가입은 아무 일도 일으키지 않습니다.
더블 클릭으로 행이 두 개 생기는 것 자체를 막고 싶나요? fetch 버전에서 페이지를 불러올 때마다 멱등성 키를 보내세요. 실수로 생기는 반복은 막지만, 일부러 두 번 가입하는 사람까지 막지는 못합니다.
가입 확인하기
Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인이 없습니다. 이런 이메일은 웹훅을 트리거로 이메일 도구에서 보내세요. 의도된 설계입니다. 출시 이메일을 보내는 도구가 확인 이메일도 보내야 수신 거부와 반송을 한곳에서 추적할 수 있습니다.
Ikiform을 이메일 도구에 연결하는 두 가지 방법:
- 코드 없이. 자동화 도구를 가리키는 웹훅 엔드포인트를 추가한 뒤, 그 도구에서 이메일 도구용 액션을 쓰세요. Zapier, Make, n8n을 참고하세요.
- 작은 릴레이. 아무 Node 호스트에서 라우트 핸들러가 서명을 검증하고 이메일 도구의 API를 호출합니다. 아래 예제는 Next.js 라우트이며, 같은 코드가 Cloudflare Worker에서도 실행됩니다.
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";
export async function POST(request: Request) {
const payload = await request.text();
try {
const event = await verifyWebhook({
payload,
headers: request.headers,
secret: process.env.IKIFORM_WEBHOOK_SECRET!,
});
if (event.type !== "submission.created") return new Response(null, { status: 204 });
const email = String(event.data.fields.email ?? "").trim().toLowerCase();
if (!email) return new Response(null, { status: 204 });
const response = await fetch(process.env.LIST_API_URL!, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.LIST_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
email,
name: event.data.fields.name ?? null,
tags: ["waitlist", String(event.data.fields.role ?? "unknown")],
}),
});
if (response.status === 409) return new Response(null, { status: 204 });
if (!response.ok) return new Response(`List answered ${response.status}`, { status: 502 });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
throw error;
}
}verifyWebhook이 서명과 타임스탬프를 검사하므로 다른 사람이 이 URL로 주소를 추가할 수 없습니다.fetch를 이메일 도구의 연락처 추가 호출로 바꾸세요. 대부분 이메일과 목록 또는 태그를 받으며, 그 도구에서 더블 옵트인이 켜져 있으면 자체 확인 이메일을 보냅니다.204로 응답하면 Ikiform에 전달이 성공했다고 알립니다.502를 돌려주면 Ikiform이 같은webhook-id로 재시도하므로, 이메일 도구가 잠시 멈춰도 가입을 잃지 않습니다.
대시보드의 Webhooks에서 엔드포인트를 추가하고 whsec_ 시크릿을 IKIFORM_WEBHOOK_SECRET에 복사하세요. 워크스페이스에는 엔드포인트를 3개까지 둘 수 있습니다. 재시도와 페이로드는 웹훅 문서에서 다루고, 자체 서버에는 각 전달을 한 번만 저장하는 버전이 있습니다.
출시일
- 폼이 Ikiform으로 POST하는 페이지를 공개합니다.
- 매주 페이지를 공유하고 새 가입과 답변을 읽습니다.
- 출시 일주일 전에 목록을 CSV로 내보내고, 중복을 지우고, 역할별로 나눕니다.
- 출시일에 이메일 도구로 목록에 이메일을 한 통 보냅니다.
목록 내보내기
대시보드에서 Submissions를 열고 대기자 명단 폼을 고른 뒤, 상태를 Received로, 기간을 All time으로 설정하고 Export CSV를 선택하세요. 열은 id, created_at, form, status이고 그 뒤에 필드마다 열이 하나씩 붙습니다. 파일은 BOM이 있는 UTF-8이라 Excel에서도 악센트 문자가 깨지지 않습니다. 한 번에 최대 100,000행까지 내보낼 수 있고, 그보다 많으면 REST API로 페이지를 넘기며 가져오세요.
Google Sheets에서 중복을 지우려면 CSV를 가져온 뒤, 이메일이 E열에 있을 때 빈 시트에서 이 수식을 쓰세요.
=SORTN(A2:F, 9^9, 2, LOWER(TRIM(E2:E)), TRUE)대소문자와 공백을 무시하고 주소마다 한 행만 남기며 이메일 순으로 정렬합니다. 또는 내보낸 파일이 있는 폴더에서 Node로 이것을 실행하세요. 주소마다 첫 행을 남기며, 값 안에 쉼표가 없다고 가정합니다. 이메일만 받는 폼이라면 이 가정이 성립합니다.
import { readFileSync, writeFileSync } from "node:fs";
const [header = "", ...rows] = readFileSync("submissions.csv", "utf8").replace(/^\uFEFF/, "").trim().split(/\r?\n/);
const columns = header.split(",");
const emailAt = columns.indexOf("email");
const seen = new Set();
const unique = rows.filter((row) => {
const email = row.split(",")[emailAt]?.trim().toLowerCase();
if (!email || seen.has(email)) return false;
seen.add(email);
return true;
});
writeFileSync("waitlist.csv", [header, ...unique].join("\n"));
console.log(`${rows.length} rows, ${unique.length} unique emails`);출시 이메일 보내기
내 받은편지함이 아니라 이메일 도구에서 보내세요. 개인 받은편지함에서 수백 개 주소로 한꺼번에 보내면 메일 서비스에는 스팸처럼 보이고, 보내는 순간부터 수신 거부 처리도 필요해집니다. 짧게 쓰세요.
Subject: Acme is live. You're in.
Hi Ada,
You joined the Acme waitlist in the spring. Today it's open.
Sign in here: https://acme.example/start
You get the first 30 days free because you waited.
Reply to this email if anything breaks. It comes to me.
Ada, founder of Acme- 무엇이 공개되었는지 말하고 가입 또는 로그인 페이지로 바로 연결하세요.
- 더 긴 체험 기간이나 할인처럼 기다려 준 데 대한 보상을 주고, 한 번만 언급하세요.
- 사람 이름으로 보내세요. 답장은 초기에 받을 수 있는 가장 좋은 피드백입니다.
role을 모았다면 창업자와 엔지니어에게 첫 문장을 다르게 보내세요. 그게 바로 이 필드를 모으는 이유입니다.
상황별 가이드
위 단계는 어디서나 통합니다. 아래 페이지는 특정 출시나 사이트 빌더에서 달라지는 점을 다룹니다.
- Product Hunt 출시 리스트Product Hunt 몇 주 전부터 출시 리스트를 모으고, 가입마다 채널 태그를 달고, 태평양 시간 리셋에 맞춰 출시일 이메일을 두 번에 나눠 보냅니다.
- 선별 기능이 있는 베타 신청서버가 강제하는 선별 질문이 있는 베타 신청 폼과, 지원자에게 점수를 매겨 목록에서 기수별 참가자를 고르는 스크립트입니다.
- 모바일 앱 대기자 명단iOS나 Android 앱의 출시 전 대기자 명단으로, 사용하는 휴대폰을 묻고 iPhone 테스터는 TestFlight로 보내고 Play 비공개 테스트를 채웁니다.
- Next.js 대기자 명단Next.js App Router 대기자 명단: 시크릿 키로 가입을 전달하는 서버 액션, useActionState, 낙관적 감사 문구, 방문자별 한도를 다룹니다.
- Framer 대기자 명단Framer에서 대기자 명단 가입을 받는 두 가지 방법: 웹훅으로 Ikiform에 보내는 Framer 폼, 또는 방문자별 한도와 스팸 검사를 유지하는 코드 컴포넌트입니다.
- Webflow 대기자 명단Webflow Form Block을 Ikiform으로 연결하고, 숨겨진 필드로 가입 경로를 태그하고, 짧은 스크립트로 모든 폼에서 Webflow의 성공 상태를 유지합니다.
- Carrd 대기자 명단Carrd 페이지에서 대기자 명단 가입 받기: Pro Plus에서 UTM 추적이 되는 Send to URL 폼, 또는 Pro Standard에서 허니팟이 있는 임베드 HTML 폼.
- 일반 HTML 대기자 명단빌드 없이 일반 HTML로 만든 완전한 대기자 명단 페이지로, JavaScript가 꺼져도 작동하고, 스크립트가 로드되면 인라인 감사 문구를 보여 주며, 필요할 때는 기본 방식으로 돌아갑니다.
- Astro 대기자 명단정적 랜딩 페이지의 Astro 대기자 명단: Action이 각 가입을 전달하고, 스크립트가 이를 호출하며, 요청 시 렌더링되는 페이지 하나가 JavaScript 없는 POST를 받습니다.
- TanStack Start 대기자 명단TanStack Start 대기자 명단: 서버 라우트 하나가 일반 폼 POST와 fetch에 모두 응답하고, validateSearch가 유입 경로와 결과를 URL로 전달합니다.
- React Router 대기자 명단모든 페이지 푸터에 있는 React Router 대기자 명단: fetcher가 라우트 액션 하나로 POST하고, 가입마다 페이지를 태그하며, JavaScript 없이도 작동합니다.
- SvelteKit 대기자 명단SvelteKit 3 대기자 명단: 훅이 첫 ?ref=를 쿠키에 보관하고, 이름 있는 폼 액션이 가입을 전달하며, 재방문자는 이미 가입했음을 확인합니다.
- Nuxt 대기자 명단프리렌더링된 랜딩 페이지의 Nuxt 대기자 명단: Nitro 라우트 하나가 $fetch와 일반 폼 POST를 받아 Zod로 검사하고, JavaScript 없이 리디렉션합니다.
작동 확인하기
페이지를 공유하기 전에 이 과정을 거치세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도 localhost에서 보낸 테스트 제출을 매달 50건 받습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 내세요. 크레딧은 만료되지 않습니다. 요금을 참고하세요.
- 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발 중에는
localhost도 추가하세요. 비워 두면 어디서 온 POST든 받습니다. - 페이지에서 폼을 작성해 제출하세요. 내 감사 페이지나 Ikiform의 호스팅 감사 페이지로 이동합니다.
- 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
- 알림 수신자마다 받은편지함을 확인하세요. 각자 필드가 담긴 이메일을 받으며, 폼에
email필드가 있으면 Reply(답장)는 제출자에게 갑니다. - 웹훅을 추가했다면 Webhooks에서 엔드포인트를 여세요. 전달이 서버의 응답과 함께
succeeded로 표시됩니다. - 허니팟을 채워서
_gotcha=bot으로 POST해 보세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다. - 아무것도 안 왔나요? 대시보드의 상태 필터를 Spam으로 바꿔 보세요. 스팸도 실제 제출과 같은 성공 응답을 받습니다.
- 같은 주소로 두 번 가입한 뒤 이메일 도구에 연락처가 여전히 하나인지 확인하세요.
문제 해결
| 문제 | 해결 방법 |
|---|---|
403 origin_not_allowed이거나 모든 POST가 스팸으로 분류됨 | 페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 localhost도 추가하세요. 등록된 도메인의 하위 도메인은 허용됩니다. |
429 rate_limited | 한 방문자가 폼의 레이트 리밋보다 많이 보냈습니다. Standard는 방문자당 분당 5회를 허용합니다. Retry-After만큼 기다리거나, 공유 IP 하나에서 POST하는 폼이라면 Relaxed나 Custom을 고르세요. |
403 challenge_required | 폼의 스팸 검사가 Ikiform으로 설정되어 있는데 fetch POST에 토큰이 없었습니다. 제출할 때마다 먼저 @ikiform/sdk의 challenge(formId)로 토큰을 받거나, 검사를 끄세요. |
| 제출은 대시보드에 있는데 이메일이나 웹훅이 오지 않음 | 스팸으로 저장된 것입니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸 아님으로 표시하면 이메일과 웹훅이 전송됩니다. |
| 브라우저가 내 감사 페이지 대신 Ikiform의 감사 페이지로 이동함 | _redirect는 도메인이 Allowed domains에 있고 https: URL일 때만 적용됩니다. 도메인을 추가하거나, 대신 폼 설정에서 Redirect after submit을 지정하세요. |
| 같은 이메일이 두 번 나타남 | 정상입니다. Ikiform은 이메일 기준으로 중복을 제거하지 않습니다. 내보낼 때 반복을 지우거나, 릴레이나 이메일 도구에서 이메일 기준으로 upsert하세요. |
| 가입이 Ikiform에는 오는데 이메일 도구에는 오지 않음 | Webhooks에서 엔드포인트를 열고 전달 로그를 읽으세요. 400은 시크릿이 틀렸다는 뜻이고, 도구의 상태 코드가 담긴 502는 도구가 연락처를 거부했다는 뜻입니다. |
402 test_limit_reached | 워크스페이스에 플랜이 없고 이번 달 무료 localhost 테스트 50건을 다 썼습니다. 플랜을 고르거나 대시보드의 Send test를 쓰세요. |
질문
직접 운영하는 서버는 필요 없습니다. 폼에는 POST할 곳이 필요하고, 그것이 바로 폼 백엔드입니다. 폼의 action을 Ikiform 엔드포인트로 지정하면 각 가입을 저장하고, 필터링하고, 전달합니다. 랜딩 페이지는 정적 페이지로 두어도 됩니다.
아니요. Ikiform은 가입한 사람이 아니라 여러분과 팀에게 이메일을 보내므로 확인 이메일이나 더블 옵트인 이메일은 없습니다. 각 가입을 웹훅으로 이메일 도구에 보내고 그곳에서 더블 옵트인을 켜세요.
숨겨진 _gotcha 허니팟을 유지하고, 기본 레이트 리밋을 켜 두고, Allowed domains에 도메인을 추가하고, 스팸 검사를 Ikiform으로 설정해 키 관리 없이 Cloudflare Turnstile 검사를 쓰세요. 일회용 주소도 스팸 점수를 올립니다.
그럴 수 있습니다. Ikiform은 받아들인 POST를 모두 보관하며 이메일 기준으로 반복을 합치지 않습니다. 목록이 있는 곳에서 중복을 제거하세요. 이메일 도구, 데이터베이스의 유니크 열, 또는 가져오기 전의 CSV에서 하면 됩니다.
Ikiform만으로는 안 됩니다. 추천 링크나 리더보드가 없습니다. 숨겨진 필드로 추천 코드를 보내 직접 셀 수도 있고, 추천이 출시의 핵심이라면 추천 중심으로 만든 대기자 명단 제품을 쓰세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매달 테스트 제출 50건을 보낼 수 있습니다. 실제 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 한 번 결제로 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수 있으며, 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.