💲

Stripe x Next.js Page Router: Create checkout session

に公開

背景

Next.js 15を使っているがまだ Page Router を使っており、Stripe 公式では App Router (Server Component) でのやり方しか書いてなかったのでメモ。
https://docs.stripe.com/checkout/quickstart

なお Firestore と Run payment with Stripe extension を使っている場合は、専用の Web SDK からセッションを作成しないと正常に機能しないので、その場合はこちら。
https://zenn.dev/maztak/articles/5c004fda3b6863

また今回は顧客データのIDを自社システムのユーザーIDと紐づける必要があるため自前でAPIを実装しているが、決済ページに飛ばして成功したら返ってくるだけでいいなら Payment Links を使って作成したURLをボタンのリンク先に指定するだけでOK。
https://docs.stripe.com/payments/no-code

package.json

生成AI開発の v0 x shadcn/ui を使っている関係で、React19など最新バージョンのライブラリばかりを入れている。

  "dependencies": {
    "@stripe/stripe-js": "^7.2.0",
    "@tailwindcss/postcss": "^4.1.4",  
    "firebase": "^10.7.2",
    "firebase-admin": "^13.2.0",
    "next": "^15.3.1",
    "next-themes": "^0.4.6",
    "postcss": "^8.5.3",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "stripe": "^18.0.0",
  }

api/checkout_sessions/index.ts

import type { NextApiRequest, NextApiResponse } from 'next';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
  apiVersion: '', // Stripe ダッシュボードで設定している API バージョン
});

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  try {
    const { priceId } = req.body;

    // Create Checkout Session
    const session = await stripe.checkout.sessions.create({
      line_items: [ // 数量が1個なら `priceId: priceId,` だけでいいはず
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode: 'subscription',
      success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`, // CHECKOUT_SESSION_ID は Stripe がリダイレクト時に付与してくれる
      cancel_url: `${req.headers.origin}/pricing`,
      automatic_tax: { enabled: true }, // 任意。Stripe Tax 税金の自動徴収を設定しているかどうかで挙動が変わりそう
    });

    return res.status(200).json({ sessionId: session.id });
  } catch (error) {
    console.error('Error creating checkout session:', error);
    return res.status(500).json({ error: 'Error creating checkout session' });
  }
}

pages/pricing/index.tsx

import React, { useState } from 'react';
import { useRouter } from 'next/router';

const PricingPage = () => {
  const router = useRouter();
  const [loading, setLoading] = useState<boolean>(false);

  const handleSubscription = async (priceId: string) => {
    setLoading(true);

    try {
      const response = await fetch('/api/checkout_sessions', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          priceId,
        }),
      });

      const { sessionId } = await response.json();

      // Redirect to Stripe Checkout
      const stripe = await getStripe();
      stripe?.redirectToCheckout({ sessionId });
    } catch (error) {
      console.error('Error:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <Button
        onClick={() => handleSubscription('your_product_price_id')}
        disabled={loading}
      >
        {loading ? '処理中...' : '登録する'}
      </Button>
   </>
  )
};

// TODO: lib/stripe.ts などに移動
const getStripe = async () => {
  const { loadStripe } = await import('@stripe/stripe-js');
  const stripePromise = loadStripe(
    process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || '',
  );
  return stripePromise;
};

export default PricingPage;

解決しない場合

お困りでしたら X のDM、もしくは Lancers などからご相談ください。
https://www.lancers.jp/profile/takuya108817

Discussion