Next.jsとmicroCMS + Stripeで簡単なECサイトテンプレートを作った話

microCMSテンプレートのシンプルなECサイトを紹介しています。microCMSやStripeを組み合わせて商品データを連携する方法や、注文時に商品データを作成する方法を解説しています。また、microCMSのWebhookを使用してデータの同期を行ったり、価格が変更された場合の処理方法も説明しています。ただし、カスタマイズやリスク要素についても言及しており、Stripe Elementsを使ったデータ連携の方法も提案しています。今後はテンプレートの更新情報や有料テンプレートの配布を検討しています。

広告ここから
広告ここまで

目次

    microCMSテンプレートにて、「シンプルなECサイトテンプレート」を公開しました。

    https://templates.microcms.io/templates/detail/61ad8e9d-77db-4155-964b-aa8acb6d7f41

    作成・公開した背景

    microCMSは、個人のポートフォリオサイトで利用しています。ただし部分的な利用のみで、プレビュー機能やWebhookなどまではしっかり利用できていませんでした。

    「一度microCMSを使ったサイト構築を、きっちりとやりきってみたい」と思ったことと、「StripeとmicroCMSを組み合わせたら、何かできないかな?」という仕事が半分混ざったようなモチベーションで、テンプレートを作ってみることにしたのがきっかけです。

    「やりきること」が大事だと思っているので、今回は「公開すること」を目的に比較的シンプルな機能・デザインで実装しています。

    システム構成

    • アプリケーション:Next.js(AppRouter)
    • コンテンツ管理: microCMS
    • 決済: Stripe (Checkout)

    microCMSとStripe Checkoutとの連携をどのように実装したか

    せっかくなので、microCMSに登録した商品データをStripe Checkoutで連携させる方法を簡単に紹介します。

    これはあくまで「簡単に済ませるなら、このやり方かな?」というものです。「もっと他にいい方法あるのでは?」と感じた方は、ぜひサンプルやコードを公開して岡本までお知らせください。

    考慮しないといけないこと

    microCMSで商品情報を管理し、Stripe Checkoutで決済を受け付ける場合は、次の事柄を意識する必要があります。

    • Stripe Checkoutで利用するため、商品情報をStripeに設定する必要がある
    • microCMSに登録した商品・価格情報を、Stripeに同期しないといけない
    • Stripeが必要とする情報を、microCMS側で入力できるようにする必要がある

    「いつ・どのタイミングで連携するか」と「Stripeが必要とする情報を、microCMSのフィールドで用意」の2点を意識して、実装を行いました。

    microCMSに登録した商品情報から、Stripeの決済フローを開始する

    今回のテンプレートでは、事前にStripeに商品登録を行わない方法を採用してみました。事前に連携するのではなく、「注文処理時に、商品データがなければ作る」方法をとっています。

    まず注文フローを開始するフォームを用意します。今回はカート機能を用意していないため、シンプルにformタグ内にinput type='hidden'で情報をセットしました。カート機能を用意する場合は、Cookieを利用することになるかと思います。

          <form action={`/api/${productId}/checkout`} method='POST'>
            <h1>{product.name}</h1>
            <p>
              {product.price.toLocaleString()} {product.currency[0]}
            </p>
            <div>
              <button disabled={!!draftKey} type='submit'>
                Buy now
              </button>
            </div>
            <input type='hidden' name='amount' value={product.price} />
            <input type='hidden' name='currency' value={product.currency} />
            <input type='hidden' name='name' value={product.name} />
            {product.featured_image ? (
              <input type='hidden' name='image' value={product.featured_image.url} />
            ) : null}
          </form>

    Next.jsのAPI側では、フォームデータから通貨・金額・商品名と画像のURLを受け取っています。またリクエストのパスから、microCMS側のデータに付与されるIDも渡しています。

      const body = await request.formData()
      const amount = body.get('amount') as FormDataEntryValue
      const currency = body.get('currency') as FormDataEntryValue
      const name = body.get('name') as FormDataEntryValue
      const image = body.get('image') as FormDataEntryValue

    取得したIDを元に、Stripeに料金データが登録されているかを確認します。存在しない場合はエラーがthrowされますが、「データがないなら作る」ということでcatchしてnullを返します。

      let { id: priceId } = await stripe.prices
        .list({
          product: productId,
        })
        .then(({ data }) => data[0])
        .catch((e) => ({ id: null }))

    priceIdnullの場合、商品データと料金データを生成します。デフォルト価格として登録しているため、商品情報の重複チェックは省きました。複数の料金(年額と月額など)を提供したい場合は、ここでカスタマイズが必要です。

    
      if (!priceId) {
        const product = await stripe.products.create({
          id: productId,
          default_price_data: {
            unit_amount: Number(amount),
            currency: currency.toString(),
          },
          name: name.toString(),
          images: [image?.toString()],
        })
        priceId =
          typeof product.default_price === 'string'
            ? product.default_price
            : product.default_price?.id ?? ''
      }

    そして最後にStripe Checkoutのセッションを開始し、リダイレクトさせます。

      const session = await stripe.checkout.sessions.create({
        mode: 'payment',
        line_items: [
          {
            price: priceId,
            quantity: 1,
          },
        ],
        cancel_url: referer,
        success_url: `${origin}?success=true`,
      })
      if (session.url) {
        return NextResponse.redirect(new URL(session.url), 303)
      } else {
        return NextResponse.json(
          {
            message: 'Failed to create a new checkout session. Please check your Stripe Dashboard.',
          },
          {
            status: 400,
          },
        )
      }

    「カート機能を実装する」や「サブスクリプション料金への対応」などを考えると、まだまだカスタマイズや工夫のやりどころはありそうですね。

    microCMS Webhookで、microCMS上での変更をStripeに同期する

    注文時にデータを作成する関係上、「後で商品情報や価格が変更された場合」への対応が別途必要です。

    今回はmicroCMSのWebhookを処理するAPIを用意しています。

    まずはリクエストをJSONで受け取りましょう。

    
      const data: MicroCMSWebhookEvent = await request.json()
      if (data.api !== 'products') {
        return new NextResponse('', {
          status: 201,
        })
      }

    その後、「データが変更された場合のイベント」のみを拾い、BeforeとAfterのデータをリクエストから取得します。

    switch (data.type) {
        case 'edit': {
          const newData = data.contents.new
          const oldData = data.contents.old
          break
        }
        default:
          break
      }

    「データが片方しかない場合」や「新しいステータスが公開ではない = 非公開記事や下書きの場合」は同期をスキップさせています。

    
          if (!newData || !oldData) {
            break
          }
          if (!newData.status.includes('PUBLISH')) {
            break
          }

    その後、microCMS上のデータを元に、Stripeの商品情報を更新します。ここでは「商品名・説明文・画像」を更新させています。

          const product = await stripe.products.retrieve(newData.id).catch(() => null)
          if (!product) break
          const newProductParam: Stripe.ProductUpdateParams = {}
          const { name, description, images } = newData.publishValue || {}
          if (name) newProductParam.name = name
          if (description) newProductParam.description = description
          if (images && images[0]) newProductParam.images = [images[0].url]
          if (Object.keys(newProductParam).length > 0) {
            await stripe.products.update(newData.id, newProductParam)
          }

    その後、Before / Afterで価格が異なる場合のみ、料金データの差し替えを行います。Stripeでは、既存のサブスクリプションユーザーをサポートするなどの理由から、料金データの「変更」ができません。そのため、「新しい料金データを作成し、古いデータをアーカイブする」処理を行います。

          if (!newData.publishValue?.price || !newData.publishValue?.currency) {
            break
          }
          if (newData.publishValue?.price !== oldData.publishValue?.price) {
            let { id: priceId } = await stripe.prices
              .list({
                product: newData.id,
              })
              .then(({ data }) => data[0])
              .catch((e) => ({ id: null }))
            if (!priceId) {
              break
            }
            const newPrice = await stripe.prices.create({
              product: newData.id,
              unit_amount: newData.publishValue.price,
              currency: newData.publishValue.currency[0],
            })
            await stripe.products.update(newData.id, {
              default_price: newPrice.id,
            })
            await stripe.prices.update(priceId, {
              active: false,
            })
          }

    microCMS側で価格を変更すると、次のように料金データがどんどん入れ替わっていくことがわかります。

    今回は「更新時の同期」のみで使いましたが、「商品情報の新規登録時に、Stripe側にデータを同期する」使い方や「microCMS側で削除したデータを、Stripeからも削除する」ような使い方も可能です。

    また、StripeのWebhookを使うことで、Stripe側で変更した場合の同期も可能です。ただし両方のWebhookを設定すると、無限ループに入る恐れがありますのでご注意ください。

    やってみた感想

    テンプレートを作ってみた感想ですが、「シンプルに連携させたが、カスタマイズを考えると簡単ではないやり方だったかもしれない」と思っています。

    価格が二重管理になるので、Webhookより「外部データ連携」の方がよいかも

    Webhookを使えば連携できますが、それでも値が2箇所で個別に保存されているのはちょっとリスク要素に感じました。

    万が一Webhook APIがエラーになった場合、Stripeの決済ページに遷移すると古い価格が出るということもありそうです。

    この辺りのリスクを減らそうとすると、外部データ連携機能を使ってStripeに価格系のデータを統合した方がよいかもしれません。

    https://blog.microcms.io/add-iframe-field/

    microCMS側で完全に管理するなら、Stripe Elementsの方が向いている?

    二重管理の原因は、Stripe Checkoutのために商品登録が必要な部分です。

    ということはこれを使わなければOKでもあって、Stripe Elementsで決済フォームを埋め込む方法にすればデータ連携周りの考慮は外せそうです。

    ただしCheckoutを辞めると、クーポンや住所・配送料金・消費税の計算なども開発要件に乗っかってくるので要注意です。

    https://www.docswell.com/s/hidetaka-stripe/5DEWEV-jp_stripes_fukuoka_vol10#p28

    今後について

    リリースしてから、「このテンプレートの更新情報とかを書くブログ欲しいよね」となってきました。ECサイトとしても、お知らせなどである程度テキストを書くスペースがある方が良いかなと思いますし、幸いまだmicroCMSのAPIは2本しか使っていません。

    そこで仕事と家次第ではありますが、どこかのタイミングで「ニュース・お知らせ機能」を追加しようかなと思っています。

    あとは・・・有料テンプレートが配布できるようになったら、Stripe Elementsを使った本格的なバージョンも個人的に試してみたいかもしれません。

    広告ここから
    広告ここまで
    Home
    Search
    Bookmark