Astro × Kinde × Stripe 月額サブスクリプション実装ガイド
Astro × Kinde × Stripe 月額サブスクリプション実装ガイド
対象スタック: Astro (SSRモード) + Cloudflare Pages/Workers + Kinde + Stripe
全体アーキテクチャ
ブラウザ
│
├─ /pricing → Kindeの Pricing Table ウィジェットを埋め込み
├─ /api/auth/* → Kinde SDK が処理(ログイン・コールバック・ログアウト)
├─ /dashboard → 認証+サブスク状態チェック後に表示
└─ /api/portal → Stripe カスタマーポータルへリダイレクト
│
Cloudflare Workers (Astro SSR)
│
├─ Kinde Auth ←→ Kinde管理画面(ユーザー管理・プラン管理)
└─ Stripe API ←→ Stripe(決済・Webhook)
STEP 0: 前提パッケージとプロジェクト初期状態の確認
# Node.js 18以上、npm 9以上が必要
node -v
npm -v
# 既存プロジェクトの確認
cat astro.config.mjs
cat package.json
astro.config.mjs が以下になっていることを確認します。output: 'server' が必須です:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server', // ← SSRモード(必須)
adapter: cloudflare({
mode: 'directory', // Cloudflare Pages向け推奨設定
}),
});
まだ @astrojs/cloudflare を追加していない場合:
npx astro add cloudflare
# プロンプトに "Yes" で答えると自動設定される
STEP 1: 依存パッケージのインストール
npm install @kinde-oss/kinde-typescript-sdk stripe
| パッケージ | 用途 |
|---|---|
@kinde-oss/kinde-typescript-sdk |
Kinde認証・プラン管理 |
stripe |
Stripe APIクライアント(Webhookの署名検証に使用) |
STEP 2: Kinde管理画面の設定
2-1. アプリケーションの作成
- app.kinde.com にログイン
- [Settings] → [Applications] → [Add application]
- 種類: Back-end web を選択
- 作成後、以下をメモ:
- Domain (例:
https://yourapp.kinde.com) - Client ID
- Client Secret
- Domain (例:
2-2. コールバックURLの登録
同じアプリケーション設定画面で:
- Allowed callback URLs:
https://your-app.pages.dev/api/auth/kinde_callback - Allowed logout redirect URLs:
https://your-app.pages.dev/
ローカル開発用に
http://localhost:4321/api/auth/kinde_callbackも追加しておくと便利です。
2-3. Stripeの接続
- [Settings] → [Billing] → [Connect Stripe]
- Stripeアカウントと連携(OAuth認証フロー)
- 連携後、Stripe側で作成した「月額商品」をKindeのプランとして登録
2-4. フィーチャーフラグの設定
Kinde管理画面で:
- [Feature flags] → [Add feature flag]
- Key:
is_pro、Type:boolean、Default value:false - 「Proプラン」のユーザーにはこのフラグが自動で
trueになるよう、[Billing] → [Plans] でプランにフラグを紐付け
2-5. Pricing Tableの有効化
- [Design] → [Widgets] → [Pricing table] を有効化
- 生成されたHTMLスニペットをメモ(STEP 5で使用)
STEP 3: Stripe管理画面の設定
3-1. 月額商品の作成
- dashboard.stripe.com → テストモードで作業
- [製品カタログ] → [製品を追加]
- 料金体系: 定期 → 月次を選択
- 作成後、
price_XXXXX形式の Price ID をメモ
3-2. Webhookの設定
Kinde経由の課金状態変更を受け取るため:
- [開発者] → [Webhook] → [エンドポイントを追加]
- URL:
https://your-app.pages.dev/api/stripe/webhook - 購読するイベント:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedcheckout.session.completed
- Webhook署名シークレット (
whsec_XXXXX) をメモ
STEP 4: 環境変数の設定
ローカル(.envファイル)
# .env(Gitにコミットしない!)
# Kinde
KINDE_DOMAIN=https://yourapp.kinde.com
KINDE_CLIENT_ID=your_client_id
KINDE_CLIENT_SECRET=your_client_secret
KINDE_REDIRECT_URL=http://localhost:4321/api/auth/kinde_callback
KINDE_LOGOUT_REDIRECT_URL=http://localhost:4321/
# Stripe
STRIPE_SECRET_KEY=sk_test_XXXXX
STRIPE_WEBHOOK_SECRET=whsec_XXXXX
.gitignore に追加されていることを確認:
.env
.env.local
Cloudflare Pages(本番)
Cloudflareダッシュボードの [設定] → [環境変数] で同じキーを本番値で設定します。KINDE_REDIRECT_URL は https://your-app.pages.dev/api/auth/kinde_callback に変更することを忘れずに。
STEP 5: ファイル構成
src/
├── lib/
│ └── kinde.ts # Kindeクライアントのシングルトン
├── middleware.ts # 認証状態をリクエスト全体に伝播
└── pages/
├── api/
│ └── auth/
│ └── [...kindeAuth].ts # 認証エンドポイント群
│ └── stripe/
│ └── webhook.ts # StripeのWebhook受信
│ └── portal.ts # カスタマーポータルへのリダイレクト
├── index.astro # トップページ
├── pricing.astro # 料金ページ
└── dashboard.astro # 有料会員専用ページ
STEP 6: Kindeクライアントの初期化(src/lib/kinde.ts)
// src/lib/kinde.ts
import {
createKindeServerClient,
GrantType,
type SessionManager,
} from "@kinde-oss/kinde-typescript-sdk";
// Cloudflare Workers環境ではprocess.envではなくimport.meta.envを使用
export function createKindeClient() {
return 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_URL,
logoutRedirectURL: import.meta.env.KINDE_LOGOUT_REDIRECT_URL,
});
}
// AstroのAPIルート用セッションマネージャー
// KindeはデフォルトでCookieベースのセッション管理を行う
export function getSessionManager(
request: Request,
responseHeaders: Headers
): SessionManager {
// @kinde-oss/kinde-typescript-sdk のCookieSessionManagerを使用
// 詳細はKindeの公式ドキュメントを参照
return {
async getSessionItem(key: string) {
// Cookieからセッション情報を取得するロジック
const cookieHeader = request.headers.get("cookie") ?? "";
const cookies = Object.fromEntries(
cookieHeader.split("; ").map((c) => c.split("=").map(decodeURIComponent))
);
return cookies[key] ?? null;
},
async setSessionItem(key: string, value: unknown) {
responseHeaders.append(
"Set-Cookie",
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}; Path=/; HttpOnly; SameSite=Lax; Secure`
);
},
async removeSessionItem(key: string) {
responseHeaders.append(
"Set-Cookie",
`${encodeURIComponent(key)}=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0`
);
},
async destroySession() {
// 全セッションCookieの削除ロジック(本番実装では要調整)
},
};
}
注意: Kinde SDKのバージョンによってSessionManagerの実装が異なります。公式ドキュメント kinde.com/docs で最新のCookie実装を確認してください。
STEP 7: 認証エンドポイント(src/pages/api/auth/[...kindeAuth].ts)
// src/pages/api/auth/[...kindeAuth].ts
import type { APIRoute } from "astro";
import { createKindeClient, getSessionManager } from "../../../lib/kinde";
export const GET: APIRoute = async ({ request, params, redirect }) => {
const kindeClient = createKindeClient();
const url = new URL(request.url);
const action = params.kindeAuth; // "login" | "logout" | "kinde_callback" | "register"
const responseHeaders = new Headers();
const sessionManager = getSessionManager(request, responseHeaders);
try {
switch (action) {
case "login": {
// Kindeのログインページへリダイレクト
const loginUrl = await kindeClient.login(sessionManager);
return redirect(loginUrl.toString());
}
case "register": {
// 新規登録ページへ
const registerUrl = await kindeClient.register(sessionManager);
return redirect(registerUrl.toString());
}
case "kinde_callback": {
// 認証後のコールバック処理
await kindeClient.handleRedirectToApp(sessionManager, url);
// 認証成功後、ダッシュボードへ
return new Response(null, {
status: 302,
headers: { ...Object.fromEntries(responseHeaders), Location: "/dashboard" },
});
}
case "logout": {
// ログアウト処理
const logoutUrl = await kindeClient.logout(sessionManager);
return new Response(null, {
status: 302,
headers: { ...Object.fromEntries(responseHeaders), Location: logoutUrl.toString() },
});
}
default:
return new Response("Not Found", { status: 404 });
}
} catch (error) {
console.error("Kinde auth error:", error);
return new Response("Authentication error", { status: 500 });
}
};
これにより以下のURLが自動的に機能します:
| URL | 機能 |
|---|---|
/api/auth/login |
ログインページへリダイレクト |
/api/auth/register |
新規登録ページへリダイレクト |
/api/auth/kinde_callback |
認証後のコールバック処理 |
/api/auth/logout |
ログアウト処理 |
STEP 8: 保護されたページの実装(src/pages/dashboard.astro)
---
// src/pages/dashboard.astro
import { createKindeClient, getSessionManager } from "../lib/kinde";
import Layout from "../layouts/Layout.astro";
const responseHeaders = new Headers();
const sessionManager = getSessionManager(Astro.request, responseHeaders);
const kindeClient = createKindeClient();
// ① ログイン状態の確認
const isAuthenticated = await kindeClient.isAuthenticated(sessionManager);
if (!isAuthenticated) {
// 未ログインはログインページへ
return Astro.redirect("/api/auth/login");
}
// ② ユーザー情報の取得
const user = await kindeClient.getUser(sessionManager);
// ③ サブスクリプション状態の確認
// Kindeの管理画面でProプランに "is_pro" フラグを紐付けておく
let isPro = false;
try {
const flag = await kindeClient.getFlag(sessionManager, "is_pro");
isPro = flag.value === true;
} catch (e) {
// フラグが存在しない場合はfalseのまま
isPro = false;
}
// ④ 未課金ユーザーは料金ページへ
if (!isPro) {
return Astro.redirect("/pricing");
}
---
<Layout title="ダッシュボード">
<main>
<h1>ようこそ、{user?.given_name}さん!</h1>
<p>月額プランをご利用いただきありがとうございます。</p>
<section>
<h2>プレミアム機能</h2>
<!-- 有料会員専用コンテンツ -->
</section>
<div>
<a href="/api/portal">サブスクリプションの管理(解約・カード変更)</a>
</div>
<div>
<a href="/api/auth/logout">ログアウト</a>
</div>
</main>
</Layout>
STEP 9: 料金ページ(src/pages/pricing.astro)
---
// src/pages/pricing.astro
import Layout from "../layouts/Layout.astro";
// Kinde Pricing TableのスニペットはKinde管理画面から取得
// [Design] → [Widgets] → [Pricing table] → スニペットをコピー
---
<Layout title="料金プラン">
<main>
<h1>料金プラン</h1>
<!-- KindeのPricing Tableウィジェットをここに貼り付け -->
<!-- 例(実際の値はKinde管理画面から取得してください): -->
<!--
<div
id="kinde-pricing-table"
data-kinde-pricing-table="true"
data-kinde-domain="https://yourapp.kinde.com"
data-kinde-plan-id="plan_XXXXX"
></div>
<script src="https://kinde.com/widgets/pricing-table.js" async></script>
-->
<!-- ログイン済みの場合はダッシュボードへ誘導 -->
<p>
すでに会員の方は <a href="/dashboard">こちら</a>
</p>
</main>
</Layout>
STEP 10: Stripeカスタマーポータル(src/pages/api/portal.ts)
ユーザーが自分でサブスクを管理(解約・プラン変更・支払い情報更新)できるページへのリダイレクトを実装します。
// src/pages/api/portal.ts
import type { APIRoute } from "astro";
import Stripe from "stripe";
import { createKindeClient, getSessionManager } from "../../lib/kinde";
export const GET: APIRoute = async ({ request, redirect }) => {
const responseHeaders = new Headers();
const sessionManager = getSessionManager(request, responseHeaders);
const kindeClient = createKindeClient();
// 認証確認
const isAuthenticated = await kindeClient.isAuthenticated(sessionManager);
if (!isAuthenticated) {
return redirect("/api/auth/login");
}
// KindeのユーザーIDからStripeのカスタマーIDを取得
// ※ KindeとStripeを連携している場合、Kindeユーザーには
// Stripeのカスタマー情報が紐付く
const user = await kindeClient.getUser(sessionManager);
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-11-20.acacia",
});
try {
// KindeのユーザーメールでStripeカスタマーを検索
const customers = await stripe.customers.list({
email: user?.email ?? "",
limit: 1,
});
if (customers.data.length === 0) {
return redirect("/pricing");
}
const customerId = customers.data[0].id;
// カスタマーポータルセッションを作成
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${new URL(request.url).origin}/dashboard`,
});
return redirect(portalSession.url);
} catch (error) {
console.error("Stripe portal error:", error);
return new Response("Error creating portal session", { status: 500 });
}
};
前提: Stripeダッシュボードの [設定] → [顧客ポータル] でポータルを有効化しておく必要があります。
STEP 11: Stripe Webhookの受信(src/pages/api/stripe/webhook.ts)
KindeとStripeを連携している場合、Kindeが自動的にフラグ更新まで行いますが、独自の課金ロジック(例: 使用量の記録、メール送信など)を実装したい場合はWebhookを使います。
// src/pages/api/stripe/webhook.ts
import type { APIRoute } from "astro";
import Stripe from "stripe";
export const POST: APIRoute = async ({ request }) => {
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-11-20.acacia",
});
const body = await request.text();
const signature = request.headers.get("stripe-signature") ?? "";
let event: Stripe.Event;
try {
// Webhook署名の検証(必須:なりすまし防止)
event = stripe.webhooks.constructEvent(
body,
signature,
import.meta.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
// イベントの種類に応じた処理
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
console.log(
`Subscription ${event.type}:`,
subscription.id,
subscription.status
);
// 必要に応じてDBの更新、メール送信などを実装
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
console.log("Subscription cancelled:", subscription.id);
// 解約時の処理(例:farewell emailの送信)
break;
}
case "checkout.session.completed": {
const session = event.data.object as Stripe.CheckoutSession;
console.log("Checkout completed:", session.id);
// 初回購入完了時の処理(例:ウェルカムメール)
break;
}
}
// Stripeには必ず200を返す(さもなくばリトライされる)
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
STEP 12: ローカル開発とデプロイ
ローカル開発
# Cloudflare Workers環境をローカルで再現
npm run dev
# または
npx wrangler pages dev -- npm run dev
Stripeのローカルテスト用にStripe CLIを使います:
# Stripe CLIのインストール(macOS)
brew install stripe/stripe-cli/stripe
# ローカルサーバーへWebhookを転送
stripe listen --forward-to localhost:4321/api/stripe/webhook
# テスト用の課金イベントを発生させる
stripe trigger customer.subscription.created
Cloudflare Pagesへのデプロイ
# ビルド
npm run build
# Wranglerでデプロイ
npx wrangler pages deploy dist/
STEP 13: セキュリティチェックリスト
デプロイ前に以下を確認してください:
-
.envファイルが.gitignoreに含まれている -
STRIPE_WEBHOOK_SECRETが設定されており、Webhook署名検証を実装している - ダッシュボード等の保護ページで必ず
isAuthenticatedチェックをしている -
is_proフラグのチェックをサーバーサイドのみで行っている(クライアントサイドのみの実装は危険) - Cloudflare Pagesの環境変数が本番値で設定されている
-
KINDE_REDIRECT_URLが本番URLになっている
よくあるハマりポイント
process.env が使えない
Cloudflare Workers環境では Node.js の process.env が使えません。import.meta.env を使います。
// ❌
const secret = process.env.STRIPE_SECRET_KEY;
// ✅
const secret = import.meta.env.STRIPE_SECRET_KEY;
Cookieのセキュア属性
本番環境(HTTPS)では Cookie に Secure 属性が必要ですが、ローカル開発(HTTP)では不要です。
const isProduction = import.meta.env.PROD;
const cookieOptions = isProduction ? "; Secure; HttpOnly; SameSite=Lax" : "; HttpOnly; SameSite=Lax";
Cloudflare Pagesのビルドコマンド設定
Cloudflareダッシュボードで:
- ビルドコマンド:
npm run build - ビルド出力ディレクトリ:
dist - Node.jsバージョン:
18以上を指定
Kindeコールバックの404
[...kindeAuth].ts のファイル名が正確であることを確認。Astroの動的ルーティングは [...param] 形式(... が重要)。