Cloudflare Pages + D1 + Brevo でニュースレター登録基盤を0円で作った

Cloudflare Pages + D1 + Brevo でニュースレター登録基盤を0円で作った 使い方・設定
Cloudflare Pages + D1 + Brevo で作るニュースレター登録基盤の全体像イメージ

静的サイトにニュースレター登録フォームを付けたいとき、手っ取り早い選択肢は Mailchimp や Brevo の埋め込みウィジェットだ。ところが、これらのウィジェットは外部スクリプトを読み込む都合上、デザインの自由度が低く、サードパーティ Cookie の問題も気になる。

Cloudflare Pages のサイトであれば、Pages Functions + D1(SQLite)で購読者データを自前管理し、Brevo の API でメール配信する構成を組むことができる。インフラコストはほぼゼロ。Cloudflare の無料枠だけで個人サービスを動かす全体像はCloudflare Pages + D1 で副業サービスを実質無料で動かす構成にもまとめている。登録・重複チェック・配信停止まで自分のコードで完結する。

この記事では、lifeevent-hub(家族向けライフイベント情報サイト)に実装したニュースレター基盤の構成と実装を紹介する。

📋 この記事でわかること

  • Cloudflare Pages Functions で subscribe / unsubscribe API を作る手順
  • D1(SQLite)で購読者テーブルを管理するマイグレーションの書き方
  • 重複登録を安全に処理するパターン(メールアドレスを外部に漏らさない設計)
  • Brevo API でコンタクトを追加・削除する実装
  • フロントエンドを fetch() ベースのインラインフィードバックフォームに書き換える方法
  • 配信停止リンクをトークンで実装する手順

構成の全体像


ブラウザ
  └─ POST /api/subscribe(Pages Functions)
        ├─ D1: subscribers テーブルに INSERT
        └─ Brevo API: コンタクト追加

配信停止リンク
  └─ GET /api/unsubscribe?token=xxx
        ├─ D1: status を 'unsubscribed' に UPDATE
        └─ Brevo API: コンタクト削除

Brevo は API キーが設定されていれば呼び出し、なければスキップする。D1 だけでも動く設計にしてあるため、「まずローカルで試す → 後から Brevo を追加」という段階的な導入ができる。

Step 1: D1 テーブルを作る

まず wrangler.toml に D1 バインディングを追加する。


# wrangler.toml
[[d1_databases]]
name = "DB"
database_name = "lifeevent-newsletter"
database_id = "your-database-id"

次にマイグレーションファイルを作成する。


-- migrations/0001_create_subscribers.sql
CREATE TABLE IF NOT EXISTS subscribers (
  id                INTEGER PRIMARY KEY AUTOINCREMENT,
  email             TEXT    NOT NULL UNIQUE,
  unsubscribe_token TEXT    NOT NULL,
  status            TEXT    NOT NULL DEFAULT 'active', -- active | unsubscribed
  subscribed_at     TEXT    NOT NULL DEFAULT (datetime('now')),
  unsubscribed_at   TEXT
);

CREATE INDEX IF NOT EXISTS idx_subscribers_email ON subscribers(email);
CREATE INDEX IF NOT EXISTS idx_subscribers_token ON subscribers(unsubscribe_token);

マイグレーションを実行する。


# ローカル D1 に適用
npx wrangler d1 migrations apply lifeevent-newsletter --local

# 本番 D1 に適用
npx wrangler d1 migrations apply lifeevent-newsletter

D1 を型安全に扱いたい場合は Drizzle ORM を載せる手もある。その構成はAstro×D1×Drizzle 副業アプリ0円構成ガイドで詳しく書いた。今回は購読者テーブル1枚なので生 SQL で進める。

D1(SQLite)に購読者テーブルを作りマイグレーションで管理するイメージ

Step 2: subscribe API を実装する

functions/api/subscribe.ts を作成する。Pages Functions はファイルのパスがそのまま API エンドポイントになる。


// functions/api/subscribe.ts
interface Env {
  DB: D1Database;
  BREVO_API_KEY?: string;
  BREVO_LIST_ID?: string; // Brevo のリスト ID(数値を文字列で)
}

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  const headers = { "Content-Type": "application/json" };

  // リクエストボディのパース
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return new Response(JSON.stringify({ error: "リクエストが不正です" }), { status: 400, headers });
  }

  // メールアドレスのバリデーション
  const email = (typeof body === "object" && body !== null && "email" in body)
    ? String((body as Record<string, unknown>).email).trim().toLowerCase()
    : "";

  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return new Response(
      JSON.stringify({ error: "メールアドレスが正しくありません" }),
      { status: 400, headers }
    );
  }

  // 配信停止リンク用のトークンを生成
  const token = crypto.randomUUID();

  // D1 に INSERT
  try {
    await env.DB.prepare(
      `INSERT INTO subscribers (email, unsubscribe_token) VALUES (?, ?)`
    ).bind(email, token).run();
  } catch (e) {
    const msg = e instanceof Error ? e.message : String(e);
    if (msg.includes("UNIQUE constraint failed")) {
      // 重複登録は 200 で返す(登録済みかどうかをメアドから判断させない)
      return new Response(JSON.stringify({ message: "registered" }), { status: 200, headers });
    }
    console.error("D1 insert error:", msg);
    return new Response(JSON.stringify({ error: "登録に失敗しました" }), { status: 500, headers });
  }

  // Brevo にコンタクトを追加(API key が設定されている場合のみ)
  if (env.BREVO_API_KEY) {
    const listId = env.BREVO_LIST_ID ? parseInt(env.BREVO_LIST_ID, 10) : undefined;
    await fetch("https://api.brevo.com/v3/contacts", {
      method: "POST",
      headers: {
        "api-key": env.BREVO_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        email,
        ...(listId ? { listIds: [listId] } : {}),
        updateEnabled: true, // 既存コンタクトも更新する
      }),
    }).catch((err) => console.error("Brevo add contact error:", err));
  }

  return new Response(JSON.stringify({ message: "subscribed" }), { status: 201, headers });
};

重複登録のポイントは、すでに登録済みでも 200 OK で返すことだ。409 Conflict などのエラーを返すと「このメアドは登録済みです」という情報を外部に漏らすことになる。registeredsubscribed の違いをレスポンスの message フィールドで区別しつつ、ステータスコードは同じにしている。

Step 3: unsubscribe API を実装する

配信停止はトークン経由で行う。メールアドレスを URL に含めると予測・悪用されやすいため、登録時に発行したランダムトークンを使う。


// functions/api/unsubscribe.ts
interface Env {
  DB: D1Database;
  BREVO_API_KEY?: string;
}

export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
  const url = new URL(request.url);
  const token = url.searchParams.get("token") ?? "";

  if (!token) {
    return Response.redirect(new URL("/unsubscribed?status=invalid", url.origin).href, 302);
  }

  const row = await env.DB.prepare(
    `SELECT id, email, status FROM subscribers WHERE unsubscribe_token = ?`
  ).bind(token).first<{ id: number; email: string; status: string }>();

  if (!row) {
    return Response.redirect(new URL("/unsubscribed?status=invalid", url.origin).href, 302);
  }

  if (row.status === "unsubscribed") {
    return Response.redirect(new URL("/unsubscribed?status=already", url.origin).href, 302);
  }

  await env.DB.prepare(
    `UPDATE subscribers SET status = 'unsubscribed', unsubscribed_at = datetime('now') WHERE id = ?`
  ).bind(row.id).run();

  // Brevo からコンタクトを削除
  if (env.BREVO_API_KEY) {
    await fetch(`https://api.brevo.com/v3/contacts/${encodeURIComponent(row.email)}`, {
      method: "DELETE",
      headers: { "api-key": env.BREVO_API_KEY },
    }).catch((err) => console.error("Brevo delete contact error:", err));
  }

  return Response.redirect(new URL("/unsubscribed?status=ok", url.origin).href, 302);
};

配信停止後は /unsubscribed?status=ok にリダイレクトし、Astro のページでステータスに応じたメッセージを出す設計だ。GET リクエストをリンクから踏むだけで完結するため、ユーザーがフォームを操作する必要がない。

ランダムトークン経由で配信停止しメールアドレスをURLに出さない設計のイメージ

Step 4: フォームを fetch() ベースに書き換える

Mailchimp や Brevo のウィジェット方式を使っていた場合、フォームを fetch() ベースのインラインフィードバック方式に書き換える。HTML + <script> で実装しているなら以下のようになる。


<form id="nlForm" novalidate>
  <div class="nl-row">
    <input id="nlEmail" type="email" placeholder="メールアドレス" required autocomplete="email" />
    <button id="nlBtn" type="submit">登録</button>
  </div>
  <p id="nlMsg" aria-live="polite"></p>
  <p class="nl-note">月2回・配信停止はいつでも可</p>
</form>

<script>
  const form = document.getElementById("nlForm");
  const emailInput = document.getElementById("nlEmail");
  const btn = document.getElementById("nlBtn");
  const msg = document.getElementById("nlMsg");

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const email = emailInput.value.trim();
    if (!email) return;

    btn.disabled = true;
    btn.textContent = "登録中…";
    msg.textContent = "";

    try {
      const res = await fetch("/api/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email }),
      });
      const data = await res.json();

      if (res.ok) {
        msg.textContent = "ご登録ありがとうございます!";
        form.style.display = "none";
      } else {
        msg.textContent = data.error ?? "登録に失敗しました。";
        btn.disabled = false;
        btn.textContent = "登録";
      }
    } catch {
      msg.textContent = "通信エラーが発生しました。";
      btn.disabled = false;
      btn.textContent = "登録";
    }
  });
</script>

aria-live="polite"<p id="nlMsg"> に付けておくと、スクリーンリーダーがメッセージ変化を読み上げてくれる。

登録ボタンが「登録中…」を経て完了表示に変わるインラインフィードバックフォームのイメージ

Step 5: Cloudflare にシークレットを設定する

Brevo の API キーとリスト ID を Cloudflare Pages の環境変数に設定する。


# Cloudflare Pages に環境変数を追加
npx wrangler pages secret put BREVO_API_KEY
npx wrangler pages secret put BREVO_LIST_ID

wrangler.toml にシークレットを書かないよう注意する。wrangler secret put で登録した値はダッシュボードの「Settings → Environment variables」にも反映される。

ハマりやすいポイント

D1 バインディング名は大文字

wrangler.tomlname = "DB" に対して、Pages Functions 側は env.DB でアクセスする。小文字にすると undefined になる。


// ❌ 小文字にすると undefined
const result = await env.db.prepare(...);

// ✅ 大文字で合わせる
const result = await env.DB.prepare(...);

ローカル開発では –local フラグが必要

wrangler pages dev でローカル起動している場合、D1 はローカルの SQLite ファイルに接続される。ローカルとリモートのデータは別なので、マイグレーションも両方に適用しておく必要がある。

Brevo の updateEnabled: true を忘れると既存コンタクトでエラー

Brevo API にすでに存在するメールアドレスを POST /contacts で追加しようとすると、updateEnabledfalse(デフォルト)の場合はエラーになる。updateEnabled: true を付けておくと冪等に動く。

D1バインディング名の大小・ローカル--localフラグ・Brevo updateEnabledのハマりどころのイメージ

よくある質問

Q. D1 の無料枠で何人くらい管理できますか?

A. D1 の無料枠は 5GB ストレージ・読み取り 500 万行/日・書き込み 10 万行/日。購読者テーブルは 1 行あたり数十バイト程度なので、5GB で数千万行は余裕で入る。個人サイトで無料枠を超えることはまずない。

Q. Brevo なしで運用することはできますか?

A. できる。BREVO_API_KEY を設定しなければ Brevo の呼び出しはスキップされ、D1 だけでメールアドレスを管理する状態になる。配信はダッシュボードから D1 のデータを CSV エクスポートして Brevo にインポートする手動フローで対応できる。

Q. メール配信の送信はどうやるんですか?

A. Brevo の「メールキャンペーン」機能から手動で配信する、または Brevo の API(POST /emailCampaigns)を使って定期バッチを組む。Cloudflare Workers の Cron Triggers と組み合わせると、毎月決まった曜日に自動配信する仕組みも作れる。

Q. GDPR 対応は必要ですか?

A. 日本からの個人サービスで日本語ユーザーが主体なら GDPR の直接適用は限定的だが、個人情報保護法の観点から「利用目的の明示」「配信停止手段の提供」は実装しておくべき。今回の実装では配信停止リンクを提供しているため最低限の対応にはなっている。

✅ まとめ

Cloudflare Pages + D1 + Brevo の構成で、外部ウィジェットに依存しないニュースレター基盤を作った。

実装のポイントを整理すると:

  • Pages Functions はファイルパスがそのまま API エンドポイントになるので設定が少ない
  • 重複登録は 200 OK + { message: "registered" } で返してメアドの存在を隠す
  • 配信停止はトークンで実装してメアドを URL に出さない
  • Brevo は updateEnabled: true で冪等に登録する
  • D1 バインディング名は大文字で統一する

まず D1 だけで動かしてメールアドレスを集め始め、配信の準備が整ったら Brevo の API キーを追加する「段階的導入」が一番ハードルが低い。静的サイトでも自前でユーザーリストを持てるようになると、メディア運用の選択肢がぐっと広がる。D1 は購読者管理以外にも使い回せて、たとえばCloudflare D1 + Astro でコメント機能を実装した記事では同じ構成でコメント欄を作っている。

コメント