🗺️

Next.js 15 + Cloudflare PagesでStripe決済付きPWAを作った話

に公開

Next.js 15 + Cloudflare PagesでStripe決済付きPWAを作った話

はじめに

「今日の天気に合わせたおでかけ先を提案してくれるアプリがあったら便利だな」と思い、おそとナビというPWAを個人開発しました。

https://osotonavi.pages.dev

現在地の天気を取得して、近くのおすすめスポット・服装・持ち物を自動提案するアプリです。プレミアム会員(¥300/月)向けに3日間の天気予報と最大10件のスポット表示も実装しています。

この記事では、Cloudflare PagesのEdge RuntimeでStripe・Auth.jsを動かす際にハマった点を中心に共有します。


技術スタック

用途 技術
フレームワーク Next.js 15 App Router
デプロイ Cloudflare Pages(@cloudflare/next-on-pages)
認証 Auth.js v5(next-auth@beta)
決済 Stripe(Checkout + Customer Portal)
天気API OpenWeatherMap
スポット情報 OpenStreetMap(Overpass API)
ランタイム Edge Runtime(export const runtime = 'edge'

全ルートを export const runtime = 'edge' で動かしています。これがあとで大きなハマりポイントになります。


機能概要

  1. 現在地の天気を取得(OpenWeatherMap)
  2. 天気に応じたおでかけ提案・服装アドバイス
  3. 近くのスポットをOpenStreetMapから検索
  4. Googleアカウントでログイン
  5. Stripeでプレミアム会員登録(¥300/月)
    • プレミアム:3日間天気予報・最大12件スポット・広告なし

ハマったポイント

1. Stripe SDKがEdge Runtimeで動かない

最初は普通にstripeパッケージをインポートして使っていました。

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

しかし、Cloudflare WorkersのEdge RuntimeではNode.js依存のnpmパッケージが動かないことが多く、Stripe SDKもその一つでした。APIルートが404を返すようになり、原因特定に時間がかかりました。

解決策:Stripe REST APIに直接fetchする

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}/`,
});

Stripe APIはREST + application/x-www-form-urlencodedなので、SDKなしでも完全に再現できます。@supabase/supabase-jsも同様の問題があったので、Supabase REST APIへの直接fetchに切り替えました。

Edge Runtimeでは「SDKが使えないかもしれない」という前提で設計することが重要です。


2. Webhookの署名検証をWeb Crypto APIで実装する

Stripeのwebhook署名検証も、通常はstripe.webhooks.constructEvent()を使いますが、Edge RuntimeではSDKが使えません。Web Crypto APIで自前実装しました。

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で標準的に使えるので、HMAC-SHA256の実装はこれで十分です。


3. Auth.js v5の設定

Auth.js v5(next-auth@beta)はv4からかなり変わっています。Cloudflare PagesでGoogle認証を動かすために必要な設定がドキュメントに書かれていなくて苦労しました。

必須の設定:

// src/auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';

export const { handlers, auth, signIn, signOut } = NextAuth({
  secret: process.env.AUTH_SECRET,
  trustHost: true,          // ← これがないとCloudflareで動かない
  session: { strategy: 'jwt' }, // ← Edge RuntimeではJWTが必須
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
});

trustHost: truesession: { strategy: 'jwt' } の2つがポイントです。これがないと「Server error」が出て認証できません。


4. Cloudflare Pagesの環境変数の扱い

Cloudflare Pagesでは環境変数の設定場所が2か所あって混乱しました。

  • Workersの「Variables and Secrets」 → 間違い(読み込まれない)
  • Pagesプロジェクトの「Settings > Environment variables」 → 正解

また、wrangler.toml[vars]セクションに書いた変数はprocess.envで読めますが、これはルートのwrangler.tomlだけが有効で、サブディレクトリのwrangler.tomlは無視されます。

# ルートの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_xxxxxxxxxxxxxx"

シークレット(APIキーなど)はCloudflareダッシュボードのPagesプロジェクト側に設定します。


プレミアム判定の実装

当初はStripeのWebhookでSupabaseにサブスクリプション情報を保存する方式を取っていましたが、Webhookの遅延や同期ズレが発生しました。

最終的にStripe APIをリアルタイムに叩いてプレミアム判定する方式に切り替えました。

export async function GET() {
  const session = await auth();
  if (!session?.user?.email) return NextResponse.json({ isPremium: false });

  // メールアドレスでStripe顧客を検索
  const searchRes = await fetch(
    `https://api.stripe.com/v1/customers/search?query=email:"${encodeURIComponent(session.user.email)}"&limit=1`,
    { headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` } }
  );
  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 ${process.env.STRIPE_SECRET_KEY}` } }
  );
  const { data: subscriptions } = await subRes.json();

  return NextResponse.json({ isPremium: (subscriptions?.length ?? 0) > 0 });
}

Stripeは信頼性が高く、この方式でも十分高速です。DBの管理が不要になってシンプルになりました。


まとめ

Cloudflare PagesのEdge Runtimeで開発する際の教訓:

  • SDKが使えないことを前提に設計する → Stripe・Supabaseは直接REST API
  • Auth.jsはtrustHost: truesession: {strategy: 'jwt'}が必須
  • 環境変数はPagesプロジェクト側に設定する
  • プレミアム判定はStripe APIをリアルタイムに叩く方が確実

Edge Runtimeの制約は多いですが、グローバルなCDNで動く軽快なアプリが無料で作れるメリットは大きいです。

おそとナビはこちらから使えます。フィードバックいただけると嬉しいです!

https://osotonavi.pages.dev

Discussion