個人で作っているサービスの先行登録フォームを、Cloudflare Pages の静的ページ+Pages Functions+D1 だけで作りました。サーバーを立てず、無料の範囲で動いています。この記事では、受け口の作り方と、小さく入れておくと安心な3つの守り(同じメールの二重登録・連投・ボット)を、実際のコードで説明します。
全体の形
public/index.html … フォーム(fetch で /api/waitlist に POST)
functions/api/waitlist.js … 受け口(Pages Functions)
schema.sql … D1 のテーブル
wrangler.toml … D1 を DB という名前で結び付ける
Pages Functions は functions/ の中のファイルの場所がそのまま URL になります。functions/api/waitlist.js は /api/waitlist です。
wrangler.toml で D1 を結び付けると、関数の中から env.DB で使えます。
name = "yattaru"
compatibility_date = "2026-10-01"
pages_build_output_dir = "public"
[[d1_databases]]
binding = "DB"
database_name = "yattaru-waitlist"
database_id = "<作成時に出る ID>"
テーブル
CREATE TABLE IF NOT EXISTS waitlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('doer','biz')),
niche TEXT,
note TEXT,
ref TEXT, -- どの投稿から来たか(?ref=)
ip_hash TEXT, -- IP そのものは残さない
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_waitlist_ip ON waitlist(ip_hash, created_at);
ポイントは2つです。
-
emailにUNIQUEを付け、二重登録をデータベースの側で防ぐ - IP アドレスは保存せず、ハッシュにして連投の判定にだけ使う。
(ip_hash, created_at)に索引を張っておくと、直近の件数を数える問い合わせが軽くなる
作るときは次のコマンドです。
npx wrangler d1 execute yattaru-waitlist --remote --file schema.sql
受け口のコード
const json = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8' } });
async function sha256(text) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
}
export async function onRequestPost({ request, env }) {
let b;
try { b = await request.json(); } catch { return json({ ok: false, error: '送信の形が正しくありません。' }, 400); }
// ① ボット避け:人には見えない欄に入力があれば、受けたふりをして捨てる
if (b.website) return json({ ok: true });
const email = String(b.email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
return json({ ok: false, error: 'メールアドレスを確かめてください。' }, 400);
}
// ② 連投の制限:同じ IP(のハッシュ)から10分に5回まで
const ipHash = await sha256((request.headers.get('cf-connecting-ip') || '') + env.HASH_SALT);
const recent = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM waitlist WHERE ip_hash = ?1 AND created_at > datetime('now','-10 minutes')"
).bind(ipHash).first();
if (recent && recent.n >= 5) return json({ ok: false, error: '少し時間をおいてください。' }, 429);
// ③ 二重登録:同じメールは数えない(エラーにもしない)
const res = await env.DB.prepare(
`INSERT INTO waitlist (email, role, niche, note, ref, ip_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(email) DO NOTHING`
).bind(email, b.role === 'biz' ? 'biz' : 'doer',
String(b.niche || '').slice(0, 120), String(b.note || '').slice(0, 600),
String(b.ref || '').slice(0, 60), ipHash).run();
return json({ ok: true, dup: res.meta.changes === 0 });
}
export function onRequest() {
return json({ ok: false, error: 'POST だけ受け付けます。' }, 405);
}
① 見えない欄でボットを避ける
フォームに、人には見えない入力欄(ここでは website)を置きます。人は入力しませんが、フォームを機械的に埋めるボットは入れがちです。入力があったら、エラーを返さず「受けたふり」をして捨てます。エラーを返すと、ボット側が欄を見分けて回避しやすくなるためです。
<div style="position:absolute;left:-9999px" aria-hidden="true">
<label>Website<input name="website" tabindex="-1" autocomplete="off"></label>
</div>
tabindex="-1" と aria-hidden で、キーボード操作や読み上げの利用者が迷い込まないようにしています。
② 連投を IP のハッシュで数える
cf-connecting-ip ヘッダーで送信元の IP が取れます。そのまま保存すると個人の情報が増えるので、秘密の文字列(HASH_SALT。Pages の環境変数に入れる)を足して SHA-256 にかけ、先頭16文字だけを残しています。同じ IP からの直近10分の件数を D1 で数え、5回を超えたら 429 を返します。
厳密な制限ではありません(IP を変えれば通ります)。目的は、同じ所からの連打や雑なスクリプトを止めることです。
③ 二重登録はデータベースに任せる
先に SELECT で存在を確かめてから INSERT すると、ほぼ同時の2回の送信で両方が通ることがあります。email の UNIQUE と ON CONFLICT(email) DO NOTHING に任せれば、その心配がありません。
run() の結果の meta.changes が0なら、すでに登録済みだったと分かります。画面では「登録済みです」と伝えるだけにして、エラー扱いにはしていません。
確かめ方
ローカルでは、D1 もローカルのものを使って動かせます。
npx wrangler d1 execute yattaru-waitlist --local --file schema.sql
npx wrangler pages dev --port 8788
次の4つを確かめてから公開しました。
| 試したこと | 期待する結果 |
|---|---|
| 正しいメールで送る |
{"ok":true,"dup":false}、D1 に1行 |
| 同じメールでもう一度 |
{"ok":true,"dup":true}、行は増えない |
| 形の崩れたメール | 400 |
| 見えない欄に入力して送る |
{"ok":true} だが、行は増えない |
公開したあとは、本番で1件だけテストの登録をして、D1 から消しています。
足りていないところ
- メールアドレスの確認(届くかどうか)はしていません。先行登録の段階では、登録の手間を減らすほうを選びました
- 連投の制限は IP 単位の簡単なものです。本格的に守るなら Cloudflare Turnstile などを足します
- 登録した人ごとの紹介コード(ランダムな6文字)には UNIQUE を付けていますが、ぶつかったときの作り直しは入れていません。ぶつかる確率は低いものの、件数が増えたら入れる予定です
この仕組みで、AI と専門の実務家をつなぐ場「YATTARU」の先行登録を受け付けています(https://yattaru.pages.dev )。作る過程は X(@yattaru_ai)で出しています。