🌊

【Next.js,Stripe】Next.js14のAPI Routes & Stripeを用いた課金機能を実装する

2024/03/26に公開

はじめに

今回は、Next.jsでStripeを用いた課金機能の実装を、備忘録として簡単に書いていきます。
Webサイトを作る人は、これで収益化できます。
Next.jsのバージョンは14.1.0です。
Stripeのアカウントを作成する方は以下の記事を参考に用意してください。
https://macareux.co.jp/blog/stripe-subsctiption

準備

Next.jsのプロジェクト作成

作成

任意のディレクトリで、以下のコマンドを用いて作成します。

npx create next-app@latest [プロジェクト名]

選択肢は全てデフォルトにしておきます。以下の画像のようになればok。

globals.cssの編集

app/globals.cssのファイルの中身を以下のように編集します。

globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

page.tsxの編集

app/page.tsxのファイルの中身を以下のように編集しておきます。

page.tsx
export default function Home() {
  return ( 
    <div>
      <h1>Home</h1>
    </div>
  );
}

.envファイルを作成

環境変数を設定します。ルートディレクトリに.envファイルを作成し、以下のように編集します。

.env
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET_KEY=
NEXT_PUBLIC_PRICE_ID_MONTH=
NEXT_PUBLIC_PRICE_ID_HALF_YEAR=
NEXT_PUBLIC_PRICE_ID_YEAR=
URL=ngrokなどを発行してください

URL以外、全てStripeのダッシュボードにて取得したものを入れます。今回は、3種類のサブスクリプションプランを用意しています。
また、Stripeのダッシュボードにて、発行したngrok/api/webhookの形式のwebhookを発行してください。

サーバーサイド

app/api/create-checkout-session/route.tsを作成

Stripeのチェックアウトセッションを作成するためのAPIエンドポイントを作成します。
appディレクトリの配下にcreate-checkout-sessionフォルダを作成し、その直下にroute.tsファイルを作成します。

route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

export async function POST(req: Request) {
  try {
    const body = await req.json();

    if (!body.priceId) {
      throw new Error("Missing price_id");
    }
    
    const priceId = body.priceId as string;
    const userId = body.userId as string;

    let customerId = ''

    const params: Stripe.Checkout.SessionCreateParams = {
      customer: customerId,
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      mode: "subscription",
      success_url: `https://${process.env.URL}/success`,
      cancel_url: `https://${process.env.URL}/`,
      metadata: { userID: userId },
    };

    const checkoutSession: Stripe.Checkout.Session =
      await stripe.checkout.sessions.create(params);

    return NextResponse.json({ result: checkoutSession, ok: true });

  } catch (error) {

    console.error(error);
    return NextResponse.json(
      { message: "something went wrong", ok: false },
      { status: 500 }
    );
  }
}

ユーザーを用意したデータベースで管理するための一例として、チェックアウトセッションを作成するパラメータの部分で、

metadata: { userID: userId }

として、クライアントから受け取ったuserIdを持たせています。
また、パラメータのうち以下の部分は、決済が成功した後またはキャンセルした後に遷移するurlを指定しています。

success_url: `https://${process.env.URL}/success`,
cancel_url: `https://${process.env.URL}/`,

app/api/webhook/route.tsを作成

StripeのWebhookを処理するためのファイルを作成します。
決済が完了した際に、Stripeからレスポンスがリアルタイムにきます。そのレスポンスを受け取り、データベースの更新やユーザーに対してメールを送信するエンドポイントを作成します。

route.ts
import { NextRequest, NextResponse } from 'next/server';
import { headers } from "next/headers";
import Stripe from 'stripe';

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.NEXT_PUBLIC_STRIPE_WEBHOOK_SECRET_KEY;

export async function POST(req: NextRequest, res: NextResponse) {   
  try {
    const signature = headers().get("stripe-signature");

    if (!signature) {
      return NextResponse.json({
        message: 'Bad request. No Stripe signature'
      }, {
        status: 400
      }) 
    }

      const body = await req.text();
      const event = stripe.webhooks.constructEvent(
        body,
        signature,
        endpointSecret
      );
    
      if (event.type === 'checkout.session.completed') {
        const checkoutSessionEvent = event.data.object as Stripe.Checkout.Session

        if (!checkoutSessionEvent.customer_details?.email) {
          throw new Error(`missing user email, ${event.id}`);
        }

        const userId = checkoutSessionEvent?.metadata?.userID as string
        const stripeId = (event.data.object as Stripe.Checkout.Session)?.customer as string
        const email = (event.data.object as Stripe.Checkout.Session)?.customer_details?.email as string

        updateDatabase(userId);
        sendEmail(email);
      }

      return NextResponse.json({
        message: 'Success'
      }, {
        status: 200
      })
  } catch (err) {
    console.error("Error processing webhook", {
      error: (err as Error).message,
      stack: (err as Error).stack,
      method: req.method,
      url: req.url,
      body: req.body
    });
    return new Response("Internal Server Error", { status: 500 });
  } 
}

以下の部分で、決済が完了した際にStripeから送られてくるイベントを元にデータベースの更新やメールの送信などの処理をさせています。

if (event.type === 'checkout.session.completed') {
const checkoutSessionEvent = event.data.object as Stripe.Checkout.Session

if (!checkoutSessionEvent.customer_details?.email) {
  throw new Error(`missing user email, ${event.id}`);
}

const userId = checkoutSessionEvent?.metadata?.userID as string
const stripeId = (event.data.object as Stripe.Checkout.Session)?.customer as string
const email = (event.data.object as Stripe.Checkout.Session)?.customer_details?.email as string

updateDatabase(userId);
sendEmail(email);
}

クライアントサイド

app/page.tsxの編集

page.tsx
"use client"

import React, { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY as string);

export default function Home() {
  const [userId, setUserId] = useState('');

  const handlePayment = async (priceId: string) => {
    if (!userId) {
      console.log("User ID is not available.");
      return <div>Loading...</div>;
    }

    if (!priceId) {
      console.log("Price ID is not available.");
      return <div>Loading...</div>;
    }

    const response = await fetch("/api/create-checkout-session", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        userId: userId,
        priceId: priceId,
      }),
    });

    const json = await response.json();
    if (!json.ok) {
      throw new Error("Failed to create checkout session.");
    }

    const stripe = await stripePromise;
    if (!stripe) {
      throw new Error("Stripe.js has not loaded.");
    }
    await stripe.redirectToCheckout({ sessionId: json.result.id });
  };

  return (
    <section style={{ display: 'flex', justifyContent: 'space-around' }}>
      <div className="product">
        <div className="description">
          <h3>1ヶ月プラン</h3>
          <h5>$1</h5>
        </div>
        <button onClick={() => handlePayment(process.env.NEXT_PUBLIC_PRICE_ID_MONTH as string)}>Checkout</button>
      </div>

      <div className="product">
        <div className="description">
          <h3>6ヶ月プラン</h3>
          <h5>$6</h5>
        </div>
        <button onClick={() => handlePayment(process.env.NEXT_PUBLIC_PRICE_ID_HALF_YEAR as string)}>Checkout</button>
      </div>

      <div className="product">
        <div className="description">
          <h3>年間プラン</h3>
          <h5>$12</h5>
        </div>
        <button onClick={() => handlePayment(process.env.NEXT_PUBLIC_PRICE_ID_YEAR as string)}>Checkout</button>
      </div>
    </section>
  );
}

クライアントサイドなので、冒頭に"use client"を忘れずにつけ、クライアントコンポーネントにします。
npm run devをすると、以下のような画面になります。
checkoutボタンを押すと、各ボタンに対応するpriceIduserIdがJSON形式で、サーバーサイドで作成したStripeのチェックアウトセッションを作成するためのAPIエンドポイントに飛ばされます。

Discussion