Next.js App RouterにStripe決済を導入する手順|個人サイト最小構成

Next.js App RouterにStripe決済を導入する手順|個人サイト最小構成

※本記事にはプロモーション(広告・アフィリエイトリンク)を含みます。

個人で Next.js サイトに決済機能を追加したい。調べると Stripe の名前が必ず出てくるが、どのライブラリをどう組み合わせてどこにコードを書けばいいのかが散在していて分かりにくい。この記事では Next.js App Router(TypeScript)に Stripe のカード決済を組み込む最小構成を、ファイル単位で示します。

私自身は自作 EC サイトに PAY.JP を REST API 直叩きで組み込んで実際に運用しています。Stripe については公式ドキュメントをもとに再現できる手順を整理しました。どちらを選ぶかを迷っている方向けに、末尾で比較も添えています。

Stripe の組み込み方式と料金を事前に確認する

Stripe には大きく 3 つの組み込み方があります。

方式 概要 向いている用途
Stripe Checkout Stripe がホストする決済ページにリダイレクト 最短で動かしたい
Payment Element 自サイトにカード入力フォームを埋め込む デザインを統一したい
Payment Links コードなしで決済 URL を発行 LP など非エンジニア向け

自作サイトのデザインに溶け込ませたいなら Payment Element が現実的な選択肢です。この記事では Payment Element を使います。

料金は執筆時点(2026年8月)の公式ドキュメントによると、国内発行カードで 3.6%(月額固定費なし)です。最新の正確な情報は Stripe 料金ページ で確認してください。

JPY はゼロ小数点通貨のため、¥1,000 は amount: 1000 と整数で渡します。ドルなど他通貨と混在する場合は単位が変わるので注意が必要です。

環境構築とパッケージのインストール

アカウントと API キーの準備

  1. stripe.com でアカウントを作成する
  2. ダッシュボード → 「開発者」→「API キー」 でテスト用キーを確認する(sk_test_ / pk_test_ から始まる)
  3. 本番公開前に本人確認(KYC)を完了させる(審査に数営業日かかる場合があります)

パッケージのインストール

npm install stripe @stripe/stripe-js @stripe/react-stripe-js
  • stripe — サーバー側(Node.js)から API を呼ぶ公式 SDK
  • @stripe/stripe-js — ブラウザ側で Stripe.js をロードするユーティリティ
  • @stripe/react-stripe-js — React コンポーネント(Elements・PaymentElement など)

環境変数の設定

.env.local に以下を追加します。

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx

NEXT_PUBLIC_ プレフィックスの変数はブラウザに露出します。シークレットキーには絶対に付けないこと。 STRIPE_WEBHOOK_SECRET はこの後 stripe listen を実行した際に発行されます。

Route Handler で PaymentIntent を作成する

App Router では app/api/ 以下に Route Handler を置きます。

// app/api/create-payment-intent/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20', // 執筆時点の安定版。公式リリースノートで最新版を確認してください
});

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

  if (!Number.isInteger(amount) || amount < 50) {
    return Response.json({ error: 'invalid amount' }, { status: 400 });
  }

  const paymentIntent = await stripe.paymentIntents.create({
    amount,
    currency: 'jpy',
    automatic_payment_methods: { enabled: true },
  });

  return Response.json({ clientSecret: paymentIntent.client_secret });
}

automatic_payment_methods: { enabled: true } を渡すことで、Stripe ダッシュボードで有効にした支払い方法(カード・Apple Pay など)が自動的に選択肢に入ります。

フロントエンドに PaymentElement を埋め込む

Stripe ロードのシングルトン化

// lib/stripe.ts
import { loadStripe } from '@stripe/stripe-js';

export const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);

loadStripe をモジュールトップで一度だけ呼ぶことで、レンダリングのたびにインスタンスが複数生成されるのを防ぎます。

決済フォームのコンポーネント

// components/CheckoutForm.tsx
'use client';

import { useState } from 'react';
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';

export function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    setIsLoading(true);
    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/checkout/complete`,
      },
    });

    if (error) {
      setMessage(error.message ?? '決済に失敗しました');
    }
    setIsLoading(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <button type="submit" disabled={isLoading || !stripe}>
        {isLoading ? '処理中...' : '支払う'}
      </button>
      {message && <p>{message}</p>}
    </form>
  );
}

ページでまとめる

// app/checkout/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import { stripePromise } from '@/lib/stripe';
import { CheckoutForm } from '@/components/CheckoutForm';

export default function CheckoutPage() {
  const [clientSecret, setClientSecret] = useState('');

  useEffect(() => {
    fetch('/api/create-payment-intent', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ amount: 3000 }), // ¥3,000 の例
    })
      .then((r) => r.json())
      .then((data) => setClientSecret(data.clientSecret));
  }, []);

  if (!clientSecret) return <p>読み込み中...</p>;

  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm />
    </Elements>
  );
}

Webhook で決済完了を DB に反映する

stripe.confirmPayment が成功してもブラウザからの通知は信頼できません。注文の確定は必ず Webhook で行います。

Webhook の Route Handler

// app/api/webhook/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

export async function POST(req: Request) {
  const body = await req.text(); // raw body が必要
  const sig = (await headers()).get('stripe-signature');

  if (!sig) return new Response('No signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
  }

  if (event.type === 'payment_intent.succeeded') {
    const pi = event.data.object as Stripe.PaymentIntent;
    // ここで Prisma などを使って DB に注文を記録する
    console.log('決済成功:', pi.id);
  }

  return new Response('ok', { status: 200 });
}

App Router では req.text() で生のリクエストボディを取得できます。Pages Router のように export const config = { api: { bodyParser: false } } の設定は不要です。

ローカルでのテスト方法

# Stripe CLI のインストール(macOS の場合)
brew install stripe/stripe-cli/stripe

# CLI でログイン
stripe login

# ローカルの Webhook にイベントを転送
stripe listen --forward-to localhost:3000/api/webhook

実行すると whsec_ から始まるシークレットが表示されます。これを .env.local の STRIPE_WEBHOOK_SECRET に設定してください。別ターミナルから stripe trigger payment_intent.succeeded で任意のイベントを送ってテストできます。

つまずきやすい点と対処

テストカード番号

公式ドキュメント記載のテストカードです。本番キーではなくテストキーを使っている状態でのみ動作します。

カード番号 結果
4242 4242 4242 4242 成功
4000 0000 0000 0002 拒否(カード停止)
4000 0025 0000 3155 3D セキュア認証が必要

有効期限は未来の任意の日付、CVV は任意の 3 桁で構いません。

HTTPS の要件

本番環境では Stripe.js の読み込みに HTTPS が必要です。私の環境は Cloudflare Tunnel 経由で公開しているためポート開放なしに HTTPS 化できていますが、VPS などを使っている場合は Let's Encrypt などの証明書が別途必要になります。

apiVersion の固定

Stripe SDK はバージョンを明示的に固定することが公式ドキュメントで推奨されています。バージョンが変わると API のレスポンス形式が変わる可能性があるためです。アップデート時はリリースノートを確認してから上げるようにしてください。

PAY.JP との比較

私が実際に組み込んだ PAY.JP と比べると、Stripe は英語ドキュメントと Next.js との組み合わせ例が豊富です。PAY.JP は日本語サポートが手厚く、執筆時点の公式サイトによると国内カードの手数料は 3.0%(グロス)とされています(最新情報は各サービスの公式ページで確認してください)。英語ドキュメントを読む手間を許容できるか、料金差をどう評価するかでどちらを選ぶかが変わると考えられます。

私が PAY.JP で実装した EMV 3D セキュアのフロー(charge 作成 → 認証ページ → コールバックで確定、失敗時は在庫を戻す)と概念的に近い仕組みが Stripe にも提供されています。ただし実装の詳細はそれぞれの公式ドキュメントで確認してください。また、同じ Next.js App Router の構成で画像配信も構築している場合は Next.js App RouterとCloudflare R2で画像を配信する実装手順 も参考になるかもしれません。

まとめ

この記事で実装した最小構成を整理します。

  1. stripe / @stripe/stripe-js / @stripe/react-stripe-js を追加
  2. .env.local にシークレットキー・公開可能キー・Webhook シークレットを設定
  3. Route Handler(/api/create-payment-intent)でサーバー側 PaymentIntent を作成
  4. Elements + PaymentElement でフォームを埋め込み、stripe.confirmPayment で送信
  5. Webhook(/api/webhook)で payment_intent.succeeded を受け取り DB に反映
  6. stripe listen でローカルテストを通してから本番キーに切り替える

次に取る行動: Stripe CLI で stripe trigger payment_intent.payment_failed や stripe trigger payment_intent.requires_action(3D セキュア要求)を手元で再現し、エラーハンドリングの網羅性を確認してください。本番キーに切り替える前にここまで済ませておくと、本番での予期しない挙動を減らせます。

この記事で触れたもの

よくある質問

StripeとPAY.JPどちらを個人サイトに選ぶべきですか?

執筆時点の公式情報では国内カード手数料はStripeが3.6%、PAY.JPが3.0%(グロス)とされています。Stripeは英語ドキュメントとNext.js向け事例が豊富で、PAY.JPは日本語サポートが手厚い傾向があります。料金差と英語ドキュメントへの慣れでトレードオフを判断するのが現実的と考えられます。

テスト環境と本番環境はどう切り替えますか?

.env.localのAPIキーをテスト用(sk_test_)から本番用(sk_live_)に差し替えるだけです。WebhookシークレットもStripeダッシュボードの本番エンドポイントで別途発行して設定し直す必要があります。テストキーと本番キーで設定ファイルを分けて管理する方法も有効です。

WebhookのローカルテストにStripe CLIは必須ですか?

必須ではありませんが最も手軽です。stripe listenコマンドが端末上にWhsec_から始まるシークレットを自動発行し、ローカルのRoute Handlerに転送してくれます。代替としてngrokなどのトンネリングツールで公開URLを作りStripeダッシュボードのWebhookエンドポイントに登録する方法もあります。

JPYの金額をStripe APIに渡すとき注意することはありますか?

JPYはゼロ小数点通貨のため、¥1,000はamount: 1000と整数で渡します。USD(¥1=$0.01相当を100で渡す)など小数点通貨と混在する実装では単位が変わるため注意が必要です。公式ドキュメントの「Zero-decimal currencies」の項目を確認してください。