Next.js 15 + Cloudflare Pages + StripeでPWAを個人開発した話
作ったもの
おそとナビ ― 現在地の天気を自動取得して、近くのおすすめスポット・服装・持ち物を提案するPWAです。
プレミアム会員(¥300/月)向けに3日間の天気予報と最大12件のスポット表示も実装しています。
技術スタック
| 用途 | 技術 |
|---|---|
| フレームワーク | Next.js 15 App Router |
| デプロイ | Cloudflare Pages(@cloudflare/next-on-pages) |
| 認証 | Auth.js v5(next-auth@beta) |
| 決済 | Stripe Checkout + Customer Portal |
| 天気 | OpenWeatherMap API |
| スポット | OpenStreetMap(Overpass API) |
| ランタイム | Edge Runtime(全ルート) |
ハマりポイント集
1. Stripe SDKがEdge Runtimeで動かない → direct fetchで解決
Cloudflare WorkersはNode.js APIの一部が使えないため、stripeパッケージが動きません。
APIルートが黙って404を返すので原因特定に時間がかかりました。
NG(SDKはクラッシュする)
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const session = await stripe.checkout.sessions.create({...});
OK(REST APIを直接叩く)
async function stripePost(path: string, body: Record<string, string>) {
const res = await fetch(`https://api.stripe.com/v1${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(body).toString(),
});
return res.json();
}
// チェックアウトセッション作成
const checkout = await stripePost('/checkout/sessions', {
customer: customerId,
mode: 'subscription',
'line_items[0][price]': process.env.STRIPE_PRICE_ID!,
'line_items[0][quantity]': '1',
success_url: `${baseUrl}/?upgraded=1`,
cancel_url: `${baseUrl}/`,
});
@supabase/supabase-js も同様にクラッシュしたので、Supabase REST APIへの直接fetchに切り替えました。
教訓:Edge Runtimeでは「SDKが動かない前提」で設計する
2. Webhookの署名検証をWeb Crypto APIで実装
Stripe Webhookの検証も通常はstripe.webhooks.constructEvent()ですが、SDKが使えないため自前実装しました。
async function verifySignature(
body: string,
signature: string,
secret: string
): Promise<boolean> {
const pairs = signature.split(',');
const timestamp = pairs.find((p) => p.startsWith('t='))?.slice(2);
const v1 = pairs.find((p) => p.startsWith('v1='))?.slice(3);
if (!timestamp || !v1) return false;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signed = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(`${timestamp}.${body}`)
);
const computed = Array.from(new Uint8Array(signed))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return computed === v1;
}
Web Crypto APIはEdge Runtimeで標準利用可能です。
3. Auth.js v5で「Server error」が出る
v5はv4からかなり変わっており、Cloudflareで動かすには2つの設定が必須でした。
// src/auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
secret: process.env.AUTH_SECRET,
trustHost: true, // 必須:ないとServer errorになる
session: { strategy: 'jwt' }, // 必須:Edge RuntimeはJWTのみ
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
});
4. Cloudflare Pagesの環境変数の罠
環境変数の設定場所を間違えると process.env で読み込めません。
| 設定場所 | 読み込める? |
|---|---|
| Workers の「Variables and Secrets」 | ❌ 読み込めない |
| Pages プロジェクトの「Settings > Environment variables」 | ✅ 正しい |
また、wrangler.tomlの[vars]はルートのファイルだけが有効で、サブディレクトリのものは無視されます。
# ルートの wrangler.toml のみ有効
name = "osotonavi"
pages_build_output_dir = "osotonavi/.vercel/output/static"
compatibility_date = "2024-12-18"
compatibility_flags = ["nodejs_compat"]
[vars]
NEXTAUTH_URL = "https://osotonavi.pages.dev"
STRIPE_PRICE_ID = "price_xxxxxx"
# APIキーなどのシークレットはダッシュボードで設定(tomlに書かない)
5. プレミアム判定はStripe APIをリアルタイムに叩く
当初はWebhook → Supabaseにサブスクリプション情報を保存する方式でしたが、同期ズレが発生しました。
export async function GET() {
const session = await auth();
if (!session?.user?.email) return NextResponse.json({ isPremium: false });
const stripeKey = process.env.STRIPE_SECRET_KEY;
const searchRes = await fetch(
`https://api.stripe.com/v1/customers/search?query=email:"${encodeURIComponent(session.user.email)}"&limit=1`,
{ headers: { Authorization: `Bearer ${stripeKey}` } }
);
const { data: customers } = await searchRes.json();
if (!customers?.length) return NextResponse.json({ isPremium: false });
const subRes = await fetch(
`https://api.stripe.com/v1/subscriptions?customer=${customers[0].id}&status=active&limit=1`,
{ headers: { Authorization: `Bearer ${stripeKey}` } }
);
const { data: subscriptions } = await subRes.json();
return NextResponse.json({ isPremium: (subscriptions?.length ?? 0) > 0 });
}
Stripeは十分高速なのでDBなしでも快適に動きます。
まとめ
| 問題 | 解決策 |
|---|---|
| Stripe SDK が動かない | REST APIへ直接fetch |
| Supabase SDK が動かない | REST APIへ直接fetch |
| Webhook署名検証ができない | Web Crypto API(HMAC-SHA256)で自前実装 |
| Auth.jsでServer error |
trustHost: true + session: {strategy: 'jwt'}
|
| 環境変数が読めない | Pagesプロジェクト側に設定 |
| プレミアム判定がズレる | Stripe APIをリアルタイムに叩く |
Edge Runtimeの制約は多いですが、グローバルCDNで動く軽快なPWAが無料で作れるのは大きなメリットです。同じ構成で詰まっている方の参考になれば幸いです。
アプリはこちら → https://osotonavi.pages.dev