ガイド4 分で読めます
新しい SaaS のウェイトリストを作る方法
自分のランディングページにローンチ前のウェイトリストを作ります。フォーム、スパム対策、重複、確認メール、ローンチ日のきれいなエクスポートまで扱います。
ランディングページにプレーンな HTML フォームを置き、訪問者のメールアドレスをフォームバックエンドに送信すれば、ローンチ前にサーバーを動かす必要はありません。登録は 1 件ずつ Webhook でローンチ時に使うメールツールに転送し、確認メールはそのツールに送らせます。ローンチ日にはリストを CSV でエクスポートし、メールアドレスで重複を取り除いて、ローンチのメールを 1 通送ります。
ウェイトリストは、SaaS のアイデアを試すいちばん安上がりな方法です。まだ存在しないもののために見知らぬ人がメールアドレスを残してくれるなら、作る理由があり、リリース時に知らせる相手のリストも手に入ります。このガイドでは、データベースもサーバーも使わずに自分のランディングページにウェイトリストを作り、ローンチ日に備えます。
ウェイトリストの仕組み
どのウェイトリストも 3 つの部分でできています。ページ上のフォーム、登録を 1 件ずつ保存する場所、そしてリストにメールを送るツールです。ここではフォームの送信先を Ikiform にします。Ikiform は送信をスパムチェックして保存し、あなたにメールで通知し、署名付き Webhook を送ります。小さなリレーか自動化ツールが、そのアドレスをメールツールに渡します。
- 訪問者がランディングページでメールアドレスを入力すると、フォームが Ikiform のエンドポイントに送信します。
- Ikiform はハニーポット、スパムスコア、レート制限をチェックしてから登録を保存します。
- 登録はダッシュボードに表示され、そこで検索したり、リストを CSV でエクスポートしたりできます。
- Ikiform はあなたとチームにメールを送ります。登録した本人にメールを送ることはありません。
- Ikiform が署名付きの submission.created Webhook を送ります。
- リレーか自動化ツールがアドレスをメールツールに追加し、メールツールが確認メールと、のちにローンチのメールを送ります。
ウェイトリストを運用する 4 つの方法
唯一の正解となるツールはありません。この表では、よくある 4 つの構成を、得意なことと後々のコストで比べます。
| 方法 | 得意なこと | トレードオフ |
|---|---|---|
| LaunchList や Waitlister などのウェイトリスト SaaS | 紹介リンク、リーダーボード、順番待ちの順位が最初から使えます。まだサイトがなくてもホストされたページがあります。 | リストは他社のプロダクトの中にあります。料金を払うかスタイルを変えない限り、ウィジェットは相手の見た目のままです。料金はたいてい登録数に応じて増えます。 |
| Typeform や Tally などのフォームビルダーの埋め込み | 洗練された 1 問ずつのフローを、コードなしで作れます。 | ページに馴染まない iframe で、独自のスクリプトを読み込みます。ブランド表示、回答数の上限、Webhook はプランしだいです。 |
| 自前のデータベースと API ルート | データと、紹介コードなどのロジックを完全に制御できます。 | スパム対策、レート制限、通知、エクスポートを自分で作る必要があり、ランディングページが静的でもサーバーが必要です。 |
| Ikiform などのフォームバックエンド | どのサイトでも自分の HTML を使えます。スパムフィルタリング、保存、メール通知、署名付き Webhook、CSV エクスポートがサーバーなしで使えます。 | 紹介のリーダーボードはなく、登録した本人へのメールもありません。それらはメールツールが担います。 |
紹介の仕組みが計画のすべてなら、ウェイトリスト SaaS を選びましょう。フォームをサイトの他の部分と同じ見た目にし、リストを持ち運べるようにして、ローンチ後にどのみち使うツールからメールを送りたいなら、フォームバックエンドを選びましょう。このガイドの残りではフォームバックエンドを使います。
フォーム
最初はメールアドレスだけを尋ねましょう。フィールドが増えるたびに登録は減ります。質問は、すでに登録してくれた人にあとからメールで聞けます。下でスタックを選んでください。選んだスタックは、このガイドのすべてのコードサンプルに引き継がれます。
登録フォームを表示したい場所にこのフォームを貼り付けます。通常の方法で送信してリダイレクトするので、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 で送信し、フォームをお礼の一文に差し替え、サーバーが返すフィールドエラーを表示します。
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 でランディングページにウェイトリストフォームを追加する方法 を読んでください。
Server Action がシークレットキーを付けて登録を転送し、useActionState がページを再読み込みせずに結果を表示します。JavaScript がまだ読み込まれていなくても、フォームは送信できます。
"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 なしで送信するので、ページはオンデマンドでレンダリングする必要があります。アダプターを追加し、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 が送信を転送し、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 でウェイトリストを作る を読んでください。
デフォルトのフォームアクションが送信を転送し、エラー時にはメッセージ付きで 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 に送信します。このコードには絶対にシークレットキーを入れないでください。代わりに 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>
);
}同じブラウザー側の送信を 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 の送信は、成功後にブラウザーをリダイレクトします。フォームの設定で 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() としてまとめており、ウェイトリストのフォームテンプレートでは 4 フィールド版を生成できます。
尋ねる価値のあるフィールド
必要なフィールドはメールアドレスだけです。ローンチ前にリストを分類したいなら、追加するのは多くても 2 つまでにしましょう。下の 4 フィールド版を試してみてください。Ikiform のバリデーターをブラウザーで実行し、保存される内容か、返ってくる 422 を表示します。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
ref を加えたものです。これは見える入力欄ではなく、ページが非表示の入力欄で埋めます。フォームの Fields タブに貼り付けると、Ikiform がサーバー側でこれを適用します。| フィールド | 尋ねる理由 | 注意点 |
|---|---|---|
email | リストそのもの | 必須。スマートフォンで適切なキーボードが出るよう type="email" を使います |
name | 個人宛てのローンチメール | 任意。名だけで十分です |
role | 創業者を先に招待したり、エンジニアには別のメールを書いたりできます | select なら、絞り込みに使いやすいきれいな回答になります |
source | お金をかける前に、どのチャネルが効くかがわかります | 自由記述は正直ですが散らかります。非表示の ref 入力欄のほうがきれいです。下を参照してください |
ref | 尋ねずにページやキャンペーンごとの登録数を数えられます | 非表示の入力欄。任意の text で、max_length は 40 |
尋ねずに登録の流入元を追跡するには、ページごとかキャンペーンごとに非表示の入力欄を追加します。_ で始まる名前は Ikiform への指示で保存されないので、ref のような普通の名前を使ってください:
<input type="hidden" name="ref" value="homepage" />
<input type="hidden" name="_redirect" value="https://example.com/thanks" />各送信データにはページの Origin とリファラーも記録されるので、ref がなくても登録がどのページから来たかがわかります。_redirect 入力欄はブラウザーを自分のサンクスページに移動させます。これは、フォームの Allowed domains のいずれかにある https: の URL でのみ機能します。
true を保存するので、その人がいつ同意したかが記録されます。ニュースレターのテンプレートにはこれが含まれています。ボットをリストに入れない
メールアドレスの入力欄がある公開フォームには、数日のうちにボットが寄ってきます。偽の登録は数字を水増しするだけでなく、ローンチのメールがスパムトラップに届いて送信者の評価を下げてしまいます。Ikiform は 4 つの防御を重ねており、最初の 2 つは設定なしで有効です。
| 防御 | 防げるもの | 設定 |
|---|---|---|
| ハニーポット | すべての入力欄を埋める単純なボット | 上のフォームにある非表示の _gotcha 入力欄 |
| スパムスコア | 使い捨てアドレス(+2)、同一内容の繰り返し(+3)、リンク、既知のスパムフレーズ。5 に達した登録はスパムとして保存されます | 不要 |
| レート制限 | ループで送信するスクリプト | デフォルトで有効: Standard で、訪問者ごとに 1 分あたり 5 件、フォーム全体で 1 分あたり 300 件 |
| 人間確認 | 本物のブラウザーを動かすボット | Spam check を Ikiform に設定します。キーの管理なしで Cloudflare Turnstile が動きます |
スパムは通常の成功の応答で保存されるので、ボットは何も学べません。スパムはメールも Webhook も送らず、使用量にもカウントされません。最初の 1 か月は週に 1 回、ダッシュボードの Spam フィルターを確認しましょう。本物の登録が入っていたらスパムではないとマークすると、その時点で Webhook が送られます。
Allowed domains にも自分のドメインを追加しましょう。他のサイトからの送信はスパムとしてマークされるか、Reject を選んだ場合は 403 origin_not_allowed で拒否されます。人間確認をオンにしても、HTML フォームは変更不要です。fetch 版は、先に @ikiform/sdk の challenge() でトークンを取得する必要があります。その 4 行はスパム対策のドキュメントにあります。
同じ人が 2 回登録する
人は別のタブから、2 台目のデバイスから、あるいは 1 週間後に忘れて、もう一度登録します。Ikiform は受け付けた送信をすべて保存し、メールアドレスで重複をまとめたり拒否したりはしません。重複した場合は次のようになります:
| ケース | 結果 |
|---|---|
| 同じメールアドレスで、フィールドが異なるか 24 時間以上あと | 通常の送信データがもう 1 件 |
| 24 時間以内に同じフィールドをもう一度 | スパムスコアに +3。5 未満なので通常の送信データがもう 1 件 |
| 使い捨てメールドメインからの同一内容の繰り返し | +3 と +2 で 5 に達し、スパムとして保存 |
そのため、ある程度の重複は想定し、リストの最終的な置き場所で取り除きましょう。手間の少ない順に 3 つの方法があります:
- メールツールで。 ほとんどのメールツールは連絡先をアドレスで管理するので、同じアドレスを 2 回追加しても連絡先が 2 件できるのではなく、1 件が更新されます。
- エクスポート時に。 どこかにインポートする前に、CSV から重複したメールアドレスを取り除きます。方法を下で 2 つ紹介します。
- リレーで。 アドレスを小文字にして前後の空白を除き、一意制約か upsert で挿入すれば、重複しても何も起きません。
ダブルクリックで 2 行できること自体を防ぎたいですか? fetch 版で、ページを読み込むごとに冪等性キーを送ってください。これで誤って繰り返される送信は防げますが、本人が意図して 2 回登録するのは防げません。
登録の確認
Ikiform はフォームに入力した本人にメールを送りません。通知メールはあなたとチームだけに届くので、自動返信、確認メール、ダブルオプトインはありません。それらは Webhook をきっかけにメールツールから送ってください。これは意図的な設計です。ローンチのメールを送るツールが確認メールも送れば、配信停止やバウンスを 1 か所で管理できます。
Ikiform とメールツールをつなぐ方法は 2 つあります:
- コードなし。 自動化ツールを指す Webhook エンドポイントを追加し、そのツールのメールツール用アクションを使います。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 にコピーします。1 つのワークスペースにはエンドポイントを 3 つまで作れます。リトライとペイロードについては Webhook のドキュメントで、各配信を 1 回だけ保存する版については自分のサーバーで説明しています。
ローンチ日
- Ikiform に送信するフォームを載せたページを公開します。
- 毎週ページを共有し、新しい登録とその回答を読みます。
- ローンチの 1 週間前に、リストを CSV でエクスポートし、重複を取り除いて、役割ごとに分けます。
- ローンチ日に、メールツールからリストに 1 通のメールを送ります。
リストをエクスポートする
ダッシュボードで Submissions を開き、ウェイトリストのフォームを選んで、ステータスを Received、期間を All time にし、Export CSV を選びます。列は id、created_at、form、status で、そのあとにフィールドごとの列が 1 つずつ続きます。ファイルは BOM 付きの UTF-8 なので、Excel でもアクセント記号などが崩れません。1 回のエクスポートは最大 100,000 行で、それを超える場合は REST API でページングしてください。
Google スプレッドシートで重複を取り除くには、CSV をインポートし、メールアドレスが E 列にある状態で、空のシートに次の数式を入れます:
=SORTN(A2:F, 9^9, 2, LOWER(TRIM(E2:E)), TRUE)大文字小文字と空白を無視してアドレスごとに 1 行を残し、メールアドレス順に並べます。あるいは、エクスポートしたファイルのあるフォルダーで 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- 何が公開されたかを伝え、登録ページかログインページに直接リンクします。
- 長めのトライアルや割引など、待ってくれたことへの特典を用意し、1 回だけ伝えます。
- 個人の名前で送ります。返信は、手に入る中で最高の初期フィードバックです。
roleを集めたなら、創業者とエンジニアで書き出しを変えましょう。それを集める理由はそこにあります。
状況に合わせて選ぶ
上の手順はどこでも使えます。以下のページでは、特定のローンチやサイトビルダーで変わる点を扱います。
- Product Hunt ローンチリストProduct Hunt の数週間前からローンチリストを集め、登録をチャネルごとにタグ付けし、太平洋時間のリセットに合わせてローンチ日のメールを 2 回に分けて送ります。
- 選考付きのベータ登録サーバーが必須を強制する選考質問付きのベータ応募フォームと、応募者を採点してリストから各コホートを選ぶスクリプトを紹介します。
- モバイルアプリのウェイトリスト使っているスマホを尋ね、iPhone のテスターを TestFlight に送り、Play のクローズドテストを埋める、iOS・Android アプリ向けの公開前ウェイトリストです。
- Next.js のウェイトリストNext.js App Router のウェイトリスト。シークレットキーで登録を転送するサーバーアクション、useActionState、楽観的なサンクス表示、訪問者ごとの制限を扱います。
- Framer のウェイトリストFramer でウェイトリストの登録を集める 2 つの方法。Ikiform への Webhook を使う Framer のフォームか、訪問者ごとの制限とスパムチェックが残るコードコンポーネントです。
- Webflow のウェイトリストWebflow の Form Block の送信先を Ikiform にし、非表示フィールドで登録に流入元のタグを付け、短いスクリプトですべてのフォームで Webflow の成功表示を残します。
- Carrd のウェイトリストCarrd のページでウェイトリストの登録を集める方法。Pro Plus では UTM を記録する Send to URL フォーム、Pro Standard ではハニーポット付きの埋め込み HTML フォームを使います。
- プレーンな HTML のウェイトリストビルド不要でプレーンな HTML だけのウェイトリストページ。JavaScript がオフでも動き、読み込めばその場でお礼を表示し、必要なときはフォールバックします。
- Astro のウェイトリスト静的なランディングページに Astro のウェイトリストを。Action が登録を転送し、スクリプトがそれを呼び、オンデマンドの 1 ページが JavaScript なしの送信を受け止めます。
- TanStack Start のウェイトリストTanStack Start のウェイトリスト。1 つのサーバールートが通常のフォーム送信と fetch の両方に応え、validateSearch が流入元と結果を URL で運びます。
- React Router のウェイトリスト全ページのフッターに置く React Router のウェイトリスト。fetcher が 1 つのルート Action に送信し、登録ごとにページを記録し、JavaScript なしでも動きます。
- SvelteKit のウェイトリストSvelteKit 3 のウェイトリスト。フックが最初の ?ref= を Cookie に保存し、名前付きのフォーム Action が登録を転送し、再訪者には登録済みと表示します。
- Nuxt のウェイトリストプリレンダーしたランディングページに Nuxt のウェイトリストを。1 つの Nitro ルートが $fetch と通常のフォーム送信を受け、Zod で確認し、JavaScript なしならリダイレクトします。
動作を確認する
ページを共有する前に、これを一通り確認してください。Ikiform に無料プランはありません。テストは無料です。プランのないワークスペースでも、localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、500 件で月 $6 からの Pro を選ぶか、買い切りにしてください。10,000 件で $49、または 50,000 件で $149 で、クレジットに有効期限はありません。料金を参照してください。
- フォームの Allowed domains に、自分のサイトのドメインと、開発中は
localhostを追加します。空にしておくと、どこからの送信も受け付けます。 - ページでフォームに入力して送信します。サンクスページか、Ikiform のホストされたページに移動します。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- 通知の受信者それぞれの受信箱を確認します。各自にフィールドを載せたメールが届き、フォームに
emailフィールドがあれば Reply は送信者宛てになります。 - Webhook を追加した場合は、Webhooks でエンドポイントを開きます。配信は
succeededと表示され、サーバーの応答も確認できます。 - ハニーポットに値を入れて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - 何も届かない場合は、ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信データと同じ成功の応答が返ります。
- 同じアドレスで 2 回登録し、メールツールの連絡先が 1 件のままであることを確認します。
トラブルシューティング
| 問題 | 対処法 |
|---|---|
403 origin_not_allowed が返るか、すべての送信がスパムになる | ページのドメインがフォームの Allowed domains に入っていません。ドメインと、ローカルテスト用に localhost を追加してください。登録したドメインのサブドメインも許可されます。 |
429 rate_limited | 1 人の訪問者が、フォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。Retry-After まで待つか、共有の 1 つの IP から送信するフォームには Relaxed か Custom を選んでください。 |
403 challenge_required | フォームのスパムチェックが Ikiform に設定されていて、fetch の送信にトークンがありませんでした。送信のたびに先に @ikiform/sdk の challenge(formId) でトークンを取得するか、チェックをオフにしてください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムは何も送りません。Spam フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |
| ブラウザーが自分のページではなく Ikiform のサンクスページに移動する | _redirect は、ドメインが Allowed domains にあり、https: の URL でない限り無視されます。ドメインを追加するか、代わりにフォームの設定で Redirect after submit を設定してください。 |
| 同じメールアドレスが 2 回表示される | 想定どおりの動作です。Ikiform はメールアドレスで重複を除去しません。エクスポート時に重複を取り除くか、リレーやメールツールでメールアドレスをキーに upsert してください。 |
| 登録は Ikiform に届くのに、メールツールには届かない | Webhooks でエンドポイントを開き、配信ログを読んでください。400 ならシークレットが間違っています。ツールのステータス付きの 502 なら、ツールが連絡先を拒否しています。 |
402 test_limit_reached | ワークスペースにプランがなく、今月の無料の localhost テスト 50 件を使い切っています。プランを選ぶか、ダッシュボードの Send test を使ってください。 |
よくある質問
自前のサーバーは不要です。フォームには送信先が必要で、それがフォームバックエンドです。フォームの action を Ikiform のエンドポイントに向ければ、Ikiform が登録を 1 件ずつ保存、フィルタリング、転送します。ランディングページは静的なままでかまいません。
いいえ。Ikiform がメールを送るのはあなたとチームで、登録した本人ではないので、確認メールやダブルオプトインのメールはありません。登録を 1 件ずつ Webhook でメールツールに送り、そちらでダブルオプトインをオンにしてください。
非表示の _gotcha ハニーポットを残し、デフォルトのレート制限をオンのままにし、Allowed domains に自分のドメインを追加して、スパムチェックを Ikiform に設定すれば、キーの管理なしで Cloudflare Turnstile のチェックが入ります。使い捨てアドレスもスパムスコアを上げます。
あります。Ikiform は受け付けた送信をすべて保存し、メールアドレスで重複をまとめません。リストの置き場所で重複を除去してください。メールツール、データベースの一意な列、またはインポート前の CSV です。
Ikiform だけではできません。紹介リンクやリーダーボードはありません。紹介コードを非表示のフィールドで渡して自分で数えるか、紹介がローンチの中心なら、紹介を軸にしたウェイトリスト製品を使ってください。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost からテスト送信を月 50 件まで送れます。本番運用には、500 件で月 $6 からの Pro か、買い切りで 10,000 件 $49、または 50,000 件 $149 を選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com