1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js 15 + Cloudflare Pages + StripeでPWAを個人開発した話

1
Posted at

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?