Cloudflare Astro + Kinde(+ Stripe)

Page content

Astroをベースとしたプロジェクト(Cloudflare Pagesへのデプロイを想定)で、KindeとStripeを組み合わせて月額サブスクリプション機能を構築する具体的な手順を解説します。

まず、関連するスキルファイルを確認します。構成の全体図と詳細な手順を作成します。—

全体の流れ

ユーザーが登録 → Kinde でログイン → Stripe で課金 → D1 にプラン状態を保存 → ページアクセス時に Middleware がプランを確認してルーティングする、というシンプルな構成です。


Step 1 — プロジェクト作成

npm create cloudflare@latest my-saas -- --framework astro
cd my-saas

wrangler.tomlcompatibility_flagsnodejs_compat を追加します。

[compatibility_flags]
compatibility_flags = ["nodejs_compat"]

Step 2 — Kinde の設定(認証)

Kinde ダッシュボードでアプリ作成

  1. kinde.com でアカウント作成
  2. Applications → Add application → 「Back-end web」を選択
  3. Callback URL: http://localhost:4321/api/auth/kinde_callback
  4. Logout URL: http://localhost:4321

パッケージインストール

npm install @kinde-oss/kinde-typescript-sdk

環境変数(.dev.vars / Cloudflare Secrets)

KINDE_DOMAIN=https://your-subdomain.kinde.com
KINDE_CLIENT_ID=xxx
KINDE_CLIENT_SECRET=xxx
KINDE_REDIRECT_URI=http://localhost:4321/api/auth/kinde_callback
KINDE_LOGOUT_REDIRECT_URI=http://localhost:4321

認証ルート作成

src/pages/api/auth/
  login.ts          → Kinde の認証URLへリダイレクト
  kinde_callback.ts → コールバック処理・セッション保存
  logout.ts         → ログアウト
// src/pages/api/auth/login.ts
import type { APIRoute } from "astro";
import { createKindeServerClient, GrantType } from "@kinde-oss/kinde-typescript-sdk";

export const GET: APIRoute = async ({ request, redirect, locals }) => {
  const client = createKindeServerClient(GrantType.AUTHORIZATION_CODE, {
    authDomain: import.meta.env.KINDE_DOMAIN,
    clientId: import.meta.env.KINDE_CLIENT_ID,
    clientSecret: import.meta.env.KINDE_CLIENT_SECRET,
    redirectURL: import.meta.env.KINDE_REDIRECT_URI,
    logoutRedirectURL: import.meta.env.KINDE_LOGOUT_REDIRECT_URI,
  });

  const loginUrl = await client.login(sessionManager); // sessionManager は Cookie ベースで実装
  return redirect(loginUrl.toString());
};

Step 3 — Cloudflare D1 セットアップ

wrangler d1 create saas-db

wrangler.toml にバインドを追加:

[[d1_databases]]
binding = "DB"
database_name = "saas-db"
database_id = "your-database-id"

マイグレーションファイル作成:

-- migrations/0001_init.sql
CREATE TABLE subscriptions (
  user_id    TEXT PRIMARY KEY,
  plan       TEXT NOT NULL DEFAULT 'free',  -- 'free' | 'pro' | 'enterprise'
  status     TEXT NOT NULL DEFAULT 'active', -- 'active' | 'canceled' | 'past_due'
  stripe_customer_id  TEXT,
  stripe_subscription_id TEXT,
  current_period_end INTEGER,
  updated_at INTEGER DEFAULT (unixepoch())
);
wrangler d1 migrations apply saas-db --local

Step 4 — Stripe の設定(課金)

npm install stripe

環境変数に追加:

STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PRO_PRICE_ID=price_xxx

Checkout セッション作成エンドポイント

// src/pages/api/stripe/checkout.ts
import Stripe from "stripe";

export const POST: APIRoute = async ({ request, locals }) => {
  const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
  const user = await getKindeUser(request); // Kinde からユーザー取得

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer_email: user.email,
    line_items: [{ price: import.meta.env.STRIPE_PRO_PRICE_ID, quantity: 1 }],
    success_url: `${request.headers.get("origin")}/dashboard?upgraded=true`,
    cancel_url: `${request.headers.get("origin")}/pricing`,
    metadata: { kinde_user_id: user.id },
  });

  return Response.redirect(session.url!);
};

Webhook エンドポイント(D1 更新)

// src/pages/api/stripe/webhook.ts
export const POST: APIRoute = async ({ request, locals }) => {
  const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
  const sig = request.headers.get("stripe-signature")!;
  const body = await request.text();

  const event = stripe.webhooks.constructEvent(body, sig, import.meta.env.STRIPE_WEBHOOK_SECRET);
  const db = locals.runtime.env.DB; // D1 バインド

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object;
      const userId = session.metadata!.kinde_user_id;
      await db.prepare(`
        INSERT INTO subscriptions (user_id, plan, status, stripe_customer_id, stripe_subscription_id)
        VALUES (?, 'pro', 'active', ?, ?)
        ON CONFLICT(user_id) DO UPDATE SET
          plan = 'pro', status = 'active',
          stripe_customer_id = excluded.stripe_customer_id,
          stripe_subscription_id = excluded.stripe_subscription_id
      `).bind(userId, session.customer, session.subscription).run();
      break;
    }
    case "customer.subscription.deleted":
    case "customer.subscription.updated": {
      const sub = event.data.object;
      const status = sub.status === "active" ? "active" : "canceled";
      await db.prepare(`UPDATE subscriptions SET status = ? WHERE stripe_subscription_id = ?`)
        .bind(status, sub.id).run();
      break;
    }
  }

  return new Response("ok");
};

Step 5 — Middleware で認証・プラン保護

// src/middleware.ts
import { defineMiddleware } from "astro:middleware";

const PROTECTED_ROUTES = ["/dashboard", "/app"];
const PRO_ROUTES = ["/app"];

export const onRequest = defineMiddleware(async (context, next) => {
  const { request, locals, redirect } = context;
  const url = new URL(request.url);

  // 保護ルートのチェック
  const needsAuth = PROTECTED_ROUTES.some(r => url.pathname.startsWith(r));
  if (!needsAuth) return next();

  // Kinde セッション確認
  const user = await getSessionUser(request);
  if (!user) return redirect("/api/auth/login");

  // Pro ルートのプランチェック
  const needsPro = PRO_ROUTES.some(r => url.pathname.startsWith(r));
  if (needsPro) {
    const db = (locals as any).runtime.env.DB;
    const sub = await db.prepare(
      `SELECT plan, status FROM subscriptions WHERE user_id = ?`
    ).bind(user.id).first();

    if (!sub || sub.plan !== "pro" || sub.status !== "active") {
      return redirect("/pricing");
    }
  }

  locals.user = user;
  return next();
});

Step 6 — Stripe Customer Portal(プラン管理)

ユーザーが自分でキャンセル・プラン変更できるポータルへのリンクを提供します。

// src/pages/api/stripe/portal.ts
export const GET: APIRoute = async ({ request, locals }) => {
  const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
  const user = locals.user;
  const db = locals.runtime.env.DB;

  const sub = await db.prepare(`SELECT stripe_customer_id FROM subscriptions WHERE user_id = ?`)
    .bind(user.id).first();

  const session = await stripe.billingPortal.sessions.create({
    customer: sub.stripe_customer_id,
    return_url: `${new URL(request.url).origin}/dashboard`,
  });

  return Response.redirect(session.url);
};

Step 7 — デプロイ

# Webhook のテスト (ローカル)
stripe listen --forward-to localhost:4321/api/stripe/webhook

# 本番シークレットを登録
wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
wrangler secret put KINDE_CLIENT_SECRET

# D1 マイグレーション適用
wrangler d1 migrations apply saas-db

# デプロイ
npm run build
wrangler pages deploy dist/

実装チェックリスト

項目 内容
Kinde Callback URL・Logout URL を本番ドメインに更新
Stripe 本番 Webhook エンドポイントを Stripe ダッシュボードで登録
D1 本番環境へのマイグレーション適用を確認
セキュリティ stripe-signature ヘッダーの検証は必ず実施
エラー処理 Webhook は冪等に実装(同じイベントが複数回届く可能性)

Astroは静的サイト生成(SSG)が得意ですが、認証や決済を扱う場合は、Cloudflare Workersの上で動くSSR(サーバーサイドレンダリング)モードで構成するのが一般的です。


1. Astroプロジェクトの準備と設定

まず、AstroプロジェクトをCloudflare適応型(SSRモード)に設定します。

Bash

# Cloudflareアダプターの追加
npx astro add cloudflare

astro.config.mjsoutput: 'server' になっていることを確認します。

JavaScript

// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'server', // SSRを有効化
  adapter: cloudflare(),
});

2. KindeとStripeの連携(ノーコード設定)

コードを書く前に、管理画面側で土台を作ります。

  1. Stripe:

    • テストモードで「月額商品」を作成し、APIキー(Secret Key)を取得します。
  2. Kinde:

    • [Settings] > [Billing] でStripeを接続し、作成した商品をKindeの「プラン」として登録します。

    • [Design] > [Widgets] で「Pricing table」を有効にすると、決済ボタン付きの料金表が自動生成されます。


3. Kinde SDKの導入と環境変数

Astro用のSDKをインストールします。

Bash

npm install @kinde-oss/kinde-typescript-sdk

.env ファイル(ローカル)および Cloudflare の環境変数に以下を設定します。

  • KINDE_DOMAIN

  • KINDE_CLIENT_ID

  • KINDE_CLIENT_SECRET

  • KINDE_REDIRECT_URL (例: https://your-app.pages.dev/api/auth/kinde_callback)


4. 認証ルートの作成(API Routes)

Astroの src/pages/api/auth/[...kindeAuth].ts を作成し、ログインやコールバックを処理するエンドポイントを作ります。これにより、/api/auth/login/api/auth/logout が自動的に機能するようになります。


5. サブスク状態の判定ロジック

Astroのページコンポーネント(.astro ファイル)のフロントマター(サーバーサイド実行部分)で、ユーザーの課金状態をチェックします。

コード スニペット

---
// src/pages/premium-feature.astro
import { createKindeServerClient, GrantType } from "@kinde-oss/kinde-typescript-sdk";

const kindeClient = createKindeServerClient(GrantType.AUTHORIZATION_CODE, {
  /* 環境変数の設定 */
});

// ログイン状態の確認
const isAuthenticated = await kindeClient.isAuthenticated(Astro.request);

if (!isAuthenticated) {
  return Astro.redirect('/api/auth/login');
}

// 権限(サブスク状態)の確認
// Kindeの管理画面で「Proプラン」に "is_pro" というフラグを紐付けておきます
const { value: isPro } = await kindeClient.getFlag(Astro.request, "is_pro");

if (!isPro) {
  // 未課金ユーザーは料金ページへリダイレクト
  return Astro.redirect('/pricing');
}
---

<h1>有料会員専用ページ</h1>
<p>月額サブスクリプションをご契約いただきありがとうございます!</p>

6. 決済とマイページの実装

  1. 料金ページ: Kindeが生成した Pricing Table のコードをAstroのページに埋め込みます。ユーザーがプランを選択すると、自動的にStripeの決済ページへ飛びます。

  2. サブスク管理: Kinde経由でStripeの「カスタマーポータル」へリンクを貼ります。ユーザーはそこから自分で解約やカード情報の更新ができます。


この構成のメリット

  • インフラ費用ほぼ0円: Astro + Cloudflare Pages + Kinde(無料枠)を組み合わせれば、ユーザーが増えるまでサーバー代はかかりません。

  • セキュリティ: クレジットカード情報はStripeが、パスワードはKindeが管理するため、開発者が重い責任を持つ必要がありません。

  • 拡張性: Astroの高速なフロントエンドと、Kindeの柔軟な権限管理により、将来的に「チームプラン」などの複雑なB2B要件にも対応可能です。

了解です。Cloudflare Workers + Astro + Kinde + Stripe + D1
👉「ログイン → サブスク → 課金ユーザーのみ利用可能」まで
**コピペで動く“最小完全構成”**を出します。

(※実務でもそのままベースにできます)


🧩 ① ディレクトリ構成

my-saas/
├─ src/
│  ├─ pages/
│  │  ├─ index.astro
│  │  ├─ pricing.astro
│  │  ├─ success.astro
│  │  ├─ api/
│  │  │  ├─ checkout.ts
│  │  │  ├─ webhook.ts
│  │  │  ├─ me.ts
│  ├─ middleware.ts
├─ wrangler.toml
├─ package.json

⚙️ ② wrangler.toml(最重要)

name = "my-saas"
main = "./dist/_worker.js"
compatibility_date = "2024-01-01"

[[d1_databases]]
binding = "DB"
database_name = "saas-db"
database_id = "xxxx-xxxx"

[vars]
STRIPE_SECRET_KEY = "sk_test_xxx"
STRIPE_WEBHOOK_SECRET = "whsec_xxx"
STRIPE_PRICE_ID = "price_xxx"
KINDE_CLIENT_ID = "xxx"
KINDE_ISSUER_URL = "https://xxx.kinde.com"

📦 ③ インストール

npm install stripe
npm install @kinde-oss/kinde-auth-astro

🔐 ④ Kinde 認証

👉 Kinde

middleware

// src/middleware.ts
import { kindeAuthMiddleware } from "@kinde-oss/kinde-auth-astro";

export const onRequest = kindeAuthMiddleware();

💳 ⑤ Stripe Checkout API

👉 Stripe

// src/pages/api/checkout.ts
import Stripe from "stripe";

export const POST = async ({ locals }) => {
  const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);

  const user = locals.kinde.getUser();

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    payment_method_types: ["card"],
    customer_email: user.email,
    line_items: [
      {
        price: import.meta.env.STRIPE_PRICE_ID,
        quantity: 1,
      },
    ],
    success_url: "http://localhost:4321/success",
    cancel_url: "http://localhost:4321/pricing",
  });

  return new Response(JSON.stringify({ url: session.url }));
};

🔔 ⑥ Webhook(課金反映)

// src/pages/api/webhook.ts
import Stripe from "stripe";

export const POST = async ({ request, env }) => {
  const stripe = new Stripe(env.STRIPE_SECRET_KEY);

  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  let event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return new Response("Webhook Error", { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;

    await env.DB.prepare(
      "INSERT INTO users (email, subscribed) VALUES (?, 1) ON CONFLICT(email) DO UPDATE SET subscribed=1"
    )
      .bind(session.customer_email)
      .run();
  }

  return new Response("ok");
};

🧠 ⑦ ユーザー状態取得API

// src/pages/api/me.ts
export const GET = async ({ locals, env }) => {
  const user = locals.kinde.getUser();

  const result = await env.DB.prepare(
    "SELECT subscribed FROM users WHERE email = ?"
  )
    .bind(user.email)
    .first();

  return new Response(
    JSON.stringify({
      subscribed: result?.subscribed === 1,
    })
  );
};

🗄️ ⑧ D1 テーブル

CREATE TABLE users (
  email TEXT PRIMARY KEY,
  subscribed INTEGER
);

🖥️ ⑨ フロント(Astro)

index.astro(課金制限)

---
const res = await fetch('/api/me');
const user = await res.json();

if (!user.subscribed) {
  return Astro.redirect('/pricing');
}
---

<h1>有料ユーザー専用ページ</h1>

pricing.astro

<button onclick="subscribe()">サブスク登録</button>

<script>
async function subscribe() {
  const res = await fetch('/api/checkout', {
    method: 'POST'
  });
  const data = await res.json();
  location.href = data.url;
}
</script>

success.astro

<h1>登録完了!</h1>
<a href="/">トップへ</a>

🚀 ⑩ デプロイ

npm run build
npx wrangler deploy

🔥 ⑪ Stripe Webhook設定

Stripeダッシュボードで:

https://your-domain/api/webhook

イベント:

checkout.session.completed

⚠️ 重要ポイント(本番前)

① Customer IDで管理(推奨)

session.customer

② 解約対応(必須)

Stripe Billing Portal使う


③ セキュリティ

  • Webhook署名チェック(済)

  • APIでも課金チェック(済)


🎯 完成イメージ

これで👇

✅ ログイン(Kinde)
✅ 課金(Stripe)
✅ 課金ユーザー判定(D1)
✅ ページ制御(Astro)

👉 フルサーバーレスSaaS完成


💡 次にやるべき強化

必要ならここまで出せます👇

  • 「解約・プラン変更」完全対応コード

  • 「無料トライアル」実装

  • 「チーム課金(複数ユーザー)」

  • 「JWT検証でAPI保護(本番レベル)」

👉 欲しいレベル教えてもらえれば“実務完成版”まで持っていきます。