本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

概要

長く Hugo で運用してきた tech.ldas.jp を Next.js (Cloudflare Workers) に移行する過程で、Google ログイン + Stripe ベースの月額/年額メンバーシップ + メンバー限定記事のソフトゲート を実装しました。

採用した構成は、調べた限りでは日本語で実装記録が見当たらない組み合わせです。Workers + Stripe は Hono フレームワークの作例がいくつかありますが、Next.js (App Router) を絡めた例はさらに少なく、better-auth や OpenNext for Cloudflare が安定したのがいずれも 2024-2025 年のため、まとまった日本語の実装ログがほぼ無い状況でした。

本記事は「公式ドキュメントは読んだが、実プロジェクトで詰まりそうなところがいくつかある」という読者を想定し、実際に詰まった箇所を中心にまとめます。

全体構成

┌─ Cloudflare Workers (tech.ldas.jp) ──────────────────────────┐
│                                                              │
│  Next.js 15 (App Router) via @opennextjs/cloudflare          │
│    ├ ページ: 記事/タグ/カテゴリ/お気に入り/メンバーシップ    │
│    ├ /api/auth/[...all]   ─→ better-auth                     │
│    ├ /api/stripe/checkout ─→ Stripe Checkout Session 発行    │
│    ├ /api/stripe/portal   ─→ Customer Portal セッション発行  │
│    └ /api/stripe/webhook  ─→ サブスク状態を D1 に同期        │
│                                                              │
│  Bindings:                                                   │
│    ├ DB     : D1 (better-auth tables + favorite + sub.)      │
│    └ ASSETS : Workers Static Assets (静的ファイル配信)       │
│                                                              │
└──────────────────────────────────────────────────────────────┘
   │
   ├──→ Google OAuth (sign-in 用、scope: email + profile のみ)
   └──→ Stripe (商品/価格/Checkout/Webhook)

技術スタックはすべて 2024-2025 年に安定したものを選んでいます。

採用補足
ホスティングCloudflare Workers Static Assets (OpenNext 経由)Free plan + R2 / KV 不使用で運用
フレームワークNext.js 15 App RouterSSG中心、認証ページのみ動的
認証better-auth v1.xGoogle OAuth のみ。email/password は無効化
DBCloudflare D1 (SQLite)better-auth + favorite + subscription テーブル
決済Stripe (test → 本番モード)Checkout Session + Customer Portal + Webhook
ORMDrizzlebetter-auth との相性が良い
スタイルTailwind v4既存 Hugo テーマからの移植

設定はほぼ全て CLI で完結する

Stripe ダッシュボードを操作したのは「アカウント本番化」のフォームだけで、それ以外の 商品作成・価格設定・Webhook 登録・Worker secrets 投入は全て curlwrangler で完結します。

SK="sk_test_..."

# 1. 商品を作る
PROD=$(curl -sS https://api.stripe.com/v1/products \
  -u "$SK:" \
  -d "name=tech.ldas.jp メンバーシップ" \
  -d "description=メンバー限定記事へのアクセス")
PROD_ID=$(echo "$PROD" | jq -r .id)

# 2. 年額 ¥5,000 の Recurring Price を作る
PRICE=$(curl -sS https://api.stripe.com/v1/prices \
  -u "$SK:" \
  -d "product=$PROD_ID" \
  -d "currency=jpy" \
  -d "unit_amount=5000" \
  -d "recurring[interval]=year")
PRICE_ID=$(echo "$PRICE" | jq -r .id)

# 3. Webhook を作る (signing secret はこのレスポンスにしか出ない)
WH=$(curl -sS https://api.stripe.com/v1/webhook_endpoints \
  -u "$SK:" \
  -d "url=https://tech.ldas.jp/api/stripe/webhook" \
  -d "enabled_events[]=checkout.session.completed" \
  -d "enabled_events[]=customer.subscription.created" \
  -d "enabled_events[]=customer.subscription.updated" \
  -d "enabled_events[]=customer.subscription.deleted")
WH_SECRET=$(echo "$WH" | jq -r .secret)

# 4. Worker secrets に投入
printf '%s' "$PRICE_ID"  | npx wrangler secret put STRIPE_PRICE_ID
printf '%s' "$WH_SECRET" | npx wrangler secret put STRIPE_WEBHOOK_SECRET

ダッシュボードでクリックして回す方式と比べて、設定ミスを git にコミットして再現可能にできる のが大きな利点です。本番モード切替時もこのスクリプトの sk_test_sk_live_ に置き換えるだけで再実行できます。

ハマりどころ 1: Workers Free plan の 10ms CPU 制限

Workers Free plan は 1 リクエストあたり CPU 時間が 10ms です。これが想像以上に厳しく、当初の素朴な実装では大量の 503 / 1102 (CPU超過) エラーが出ました。

問題: posts のメタデータを posts-{lang}.json (約 9 MB) として /public/_data/ に置き、リクエストごとに JSON.parse していた → コールド Worker で 10ms を確実に超えて 503。

対策: posts-index を Worker のバンドルにそのまま埋め込む 構成に変更しました。

// scripts/build-posts-bundle.mjs (要旨)
const tsContent = [
  "// AUTO-GENERATED — DO NOT EDIT",
  "/* eslint-disable */",
  "// @ts-nocheck",
  "",
  `export const POSTS_INDEX_JA = ${JSON.stringify(indexBundle.ja)};`,
  `export const POSTS_INDEX_EN = ${JSON.stringify(indexBundle.en)};`,
  ...
].join("\n");
await fs.writeFile("src/generated/posts-index.ts", tsContent);
// src/lib/posts.ts
import { POSTS_INDEX_JA, POSTS_INDEX_EN } from "@/generated/posts-index";

export async function getAllPostMeta(lang: Lang): Promise<PostMeta[]> {
  const data = lang === "ja" ? POSTS_INDEX_JA : POSTS_INDEX_EN;
  return (data as PostMeta[]).map((p) => ({ ...p, slug: String(p.slug), lang }));
}

JSON.parse コストが消え、Worker 起動時の JS 評価で1度メモリに乗るだけになります。Worker bundle サイズは 1MB 弱増えますが、Free plan の 1MB 圧縮上限内に収まりました (gzip で約 200KB)。

記事本文はサイズが大きいため引き続き public/_data/posts/{lang}/{slug}.json に分割し、必要な記事だけ env.ASSETS.fetch で取得しています。

加えて、Next.js の Link が hover 時に大量のプリフェッチを飛ばす挙動も同症状を引き起こすため、自前の Link ラッパーで prefetch={false} を全面適用しています。

// src/components/Link.tsx
import NextLink from "next/link";
export default function Link({ prefetch = false, ...rest }) {
  return <NextLink prefetch={prefetch} {...rest} />;
}

ハマりどころ 2: Stripe SDK を Workers で動かす

Stripe Node SDK は Node の http モジュールに依存しているため、Cloudflare Workers でそのまま使うと壊れます。createFetchHttpClient を明示的に指定します。

// src/lib/stripe.ts
import Stripe from "stripe";

export function getStripe(env: { STRIPE_SECRET_KEY: string }): Stripe {
  return new Stripe(env.STRIPE_SECRET_KEY, {
    apiVersion: "2026-04-22.dahlia",
    httpClient: Stripe.createFetchHttpClient(),
  });
}

また、Webhook 署名検証も同期版の constructEvent だと WebCrypto が使えず失敗します。constructEventAsync を使う必要があります。

const event = await stripe.webhooks.constructEventAsync(
  await request.text(),
  signature,
  env.STRIPE_WEBHOOK_SECRET,
);

さらに 2025 年以降の Stripe API では subscription.current_period_end がトップレベルから個別の items.data[*].current_period_end に移動しています。SDK の型ではトップレベルが消えていることがあるため、unknown 経由でキャストする必要がありました。

const subAny = sub as unknown as {
  current_period_end?: number;
  items?: { data?: { current_period_end?: number }[] };
};
const endSec =
  subAny.current_period_end ??
  subAny.items?.data?.[0]?.current_period_end ??
  0;

ハマりどころ 3: SSG と members_only ゲートの両立

メンバー限定記事の判定はリクエスト時に headers() を呼んでセッションを読みます。これを SSG で生成しようとすると Page changed from static to dynamic at runtime, reason: headers というエラーで 500 になります。

シンプルな回避策として、記事ページ全体を force-dynamic に倒しました。

// src/app/[lang]/posts/[slug]/page.tsx
export const dynamic = "force-dynamic";

posts-index がメモリに載っているので、getPostBySlug は per-post JSON を 1 回 fetch するだけで済み、レスポンスは数百ミリ秒で返ります。Free plan の CPU 制限内に収まる範囲でした。

メンバー判定のロジックは以下のような形です。

let signedIn = false, isMember = false;
if (post.members_only) {
  const { env } = await getCloudflareContext();
  const auth = getAuth(env);
  const session = await auth.api.getSession({ headers: await headers() });
  signedIn = !!session?.user;
  if (signedIn) isMember = await isActiveMember(env, session!.user.id);
}
const locked = !!post.members_only && !isMember;
const bodyHtml = locked
  ? truncateHtmlForPreview(post.contentHtml, { lang }).html
  : post.contentHtml;

isActiveMember は subscription テーブルを引いて status が active または trialing なら true を返す薄いヘルパーです。

ハマりどころ 4: DNS 切替時の Pages との衝突

Pages から Workers への切替時、tech.ldas.jp の CNAME が nakamura196.pages.dev に向いたまま wrangler deploy を打つと以下のエラーになります。

Hostname 'tech.ldas.jp' already has externally managed DNS records.

Pages の Custom Domain と Workers の Custom Domain は同じホスト名を共有できません。Pages 側から先に外す必要があります。

# Pages のドメイン binding を削除
TOKEN=$(grep oauth_token ~/Library/Preferences/.wrangler/config/default.toml \
  | cut -d'"' -f2)
ACCOUNT="..."
curl -X DELETE \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/pages/projects/<proj>/domains/tech.ldas.jp" \
  -H "Authorization: Bearer $TOKEN"

その後さらに DNS の CNAME レコードも手動で削除する必要がありました。wrangler の OAuth トークンには zone:edit 権限が含まれていないため、レコード削除はダッシュボードからの操作になります。ここだけは CLI 完結できませんでした。

CNAME を削除してから再度 wrangler deploy すると、Workers 側で tech.ldas.jp 用の DNS と SSL 証明書が自動発行されます。

better-auth と組み合わせる際のテーブル設計

subscription テーブルは userId を主キーにすると「1 ユーザー 1 サブスクリプション」を DB レベルで担保できて綺麗でした。

// src/db/schema.ts (要旨)
export const subscription = sqliteTable("subscription", {
  userId: text("user_id").primaryKey().references(() => user.id),
  stripeCustomerId: text("stripe_customer_id"),
  stripeSubscriptionId: text("stripe_subscription_id"),
  status: text("status", { enum: ["active", "trialing", "canceled", ...] }),
  currentPeriodEnd: integer("current_period_end", { mode: "timestamp_ms" }),
});

better-auth の account.accountLinking.enabled = true を設定しておくと、過去にメール+パスワードで登録していたユーザーが後から同じメールで Google ログインしたときに同一 user として認識され、既存のサブスク情報も引き継がれます。

OAuth トークンと PII の最小化

better-auth の databaseHooks を使って、account テーブルへの保存時に access/refresh/id_token をすべて null に書き換えています。Google API を再呼び出しする予定がない場合、これらのトークンを保管するメリットは無く、DB が漏洩した時の被害範囲を縮小できます。

databaseHooks: {
  account: {
    create: {
      before: async (data) => ({
        data: {
          ...data,
          accessToken: null,
          refreshToken: null,
          idToken: null,
        },
      }),
    },
  },
  session: {
    create: {
      before: async (data) => ({
        data: { ...data, userAgent: null, ipAddress: null },
      }),
    },
  },
},
advanced: {
  ipAddress: { ipAddressHeaders: [] },
},

PII 最小化はプライバシーポリシーで宣言するためにも、可能な範囲で実施しておく方が説明しやすくなります。

本番化フォームでの選択肢 (日本固有)

Stripe アカウントの本番化フォームでいくつか迷ったポイントを記録しておきます。

  • 既存の GitHub Sponsors / Buy Me a Coffee の Stripe Connect アカウントが選択肢に出てくる ことがありますが、これらは外部サービスが管理する Connect アカウントで、自前の商品販売には使えません。「新しいアカウントを作成」を選ぶ必要があります。
  • カテゴリ選択は「ブログおよび記事」が最適でした。 デジタルサービス分類の中では「ソフトウェア」を選ぶと「ソフトウェア販売 ≠ ブログ会員」と齟齬を疑われそうな気がしたため、より実態に近いものを選んでいます。
  • 割賦販売法のセキュリティチェックリスト が Web 決済を扱う日本の事業者全員に求められます。Stripe + Cloudflare の組み合わせなら、ほぼ全項目に「Stripe 側で実施」「Cloudflare 側で実施」をチェックして提出できます。
  • 明細書表記 は漢字・カタカナ・英字の3つを登録します。漢字欄は実際にはフリーテキストなので、ローマ字ドメイン名のサービスならカタカナで埋めて構いません。
  • 個人携帯番号の利用明細表示はオフ推奨 です。Stripe との本人確認用に番号を登録するのは必須ですが、領収書に出すかは別設定です。デジタルコンテンツの場合チャージバック削減効果は限定的で、プライバシーリスクの方が大きいためオフが無難でした。
  • Stripe Tax と Climate は最初はスキップで構いません。Tax は 0.5% の追加手数料が乗るため、課税事業者になる年商規模 (1000 万円超) になってから検討で十分です。

価格戦略

最初は月額 ¥500 で開始しましたが、運用検討の中で 年額 ¥5,000 の単一プラン に落ち着きました。理由は以下です。

  • 月額より年額の方が「お試し」と「本格コミット」のあいだの中間的判断が減り、ユーザーの心理的ハードルが下がる
  • 月課金の請求イベントが毎月発生しないため、解約タイミングが「次回更新時」だけに集中して運用が楽
  • DH/研究系の読者層は、年契約にあまり抵抗が無い (大学のメーリングリスト的な感覚)
  • 月¥500 = 年 ¥6,000 と比べると ¥1,000 (17%) の年契約割引になり、お得感を出せる

特典は意図的に絞っています。

✓ サイト運営とコンテンツ制作の継続支援 (主)
✓ メンバー限定記事へのアクセス

「毎月の制作レポート」のような継続更新を約束する特典は最初から外しました。個人運営で約束を履行し続けるのは想像以上に重く、義務感で書いた記事は質も落ちます。「気が向いたら書く、書かなくても怒られない」状態を維持する方が、長期的には支援者にとっても作者にとっても健全だと判断しました。

まだやっていないこと・課題

  • R2 incremental cache の有効化: 今は in-memory キャッシュで運用しているため、Worker のコールドスタート時に SSG ページが再生成されます。R2 を使えば永続キャッシュになるはずですが、無料枠で動いている現状で十分なのと、R2 の有効化に追加の設定が必要なため後回しにしています。
  • 特定商取引法に基づく表記の作成: 日本で月額/年額のサブスク販売を継続する場合、いずれ必要になります。
  • 学割や複数 tier の追加: 大学院生向けの ¥2,500/年 など、価格バリエーションを後から追加できる形にしてあります。

まとめ

Cloudflare Workers + better-auth + Stripe の組み合わせは、調べた限り日本語の実装記録がほぼ無いため、初手の試行錯誤コストはかかりました。一方で、選択肢としては以下の特徴があります。

  • 月コストがほぼゼロ (Workers Free + D1 free + Stripe 手数料のみ)
  • ベンダーロックインが小さい (Stripe 以外は OSS または互換実装あり)
  • Next.js の機能はほぼ全部使える (App Router / Server Components / Streaming)

「Vercel Pro $20/月を払いたくない、自分で組める範囲で組む」という個人運営との相性が良いです。逆に「公式チュートリアル通りにすぐ動かしたい」「コミュニティのサポートを期待したい」場合は、Vercel + Auth.js + Stripe の素直な構成の方が消耗しません。

このブログ自体が現在この構成で動いているので、実体験に基づく内容としてここに記録しておきます。