# Next.js + TypeScript で payjp.js実装してみた

# Next.js + TypeScript での Payjp.js実装してみた

クレカ支払い実装をpayjp.jsとNext.js + TypeScriptで実装する機会があったので、備忘録として記事にしてみました。


# 🚀 今回の実装に関わるライブラリ一覧

| ライブラリ名               | バージョン    |
|------------------------|-----------|
| next                  | 14.2.15  |
| react                 | 18.3.1   |
| typedef-payjp-js      | 1.2.0    |
| @types/react         | 18.3.12  |
| @types/react-dom     | 18.3.1   |
| @types/node          | 20.17.7  |
| typescript           | 5.7.2    |
| tailwindcss          | 3.4.15   |



## 💳 Pay.jpとpayjp.jsとは？

**Pay.jp** は日本国内向けのオンライン決済サービスで、クレジットカード決済、サブスクリプション、Apple Pay / Google Pay などを安全かつ簡単に組み込めます。

その中核となるフロントエンドライブラリが **payjp.js** です。主な役割は：
- クレジットカード情報を **トークン化**
- 自社サーバにカード番号を送らず、安全に決済
- PCI DSS準拠のセキュアな決済フロー実現

## 🔄 クレジットカード決済の仕組み

1. ユーザーがWebサイトでクレジットカード情報を入力
2. フロント側（payjp.js）がカード情報をトークン化
3. バックエンドにトークンを送信
4. バックエンドがトークンを使ってpay.jp APIに課金リクエスト
5. Pay.jpがカード会社に与信確認
6. 承認されると決済完了

この仕組みを使うことで、加盟店側はカード情報を直接保存・処理しなくてもよく、PCI DSSへの対応負担を大幅に軽減できます。

## ⚙️ Next.js + TypeScriptでのpayjp.js実装手順

### 1. ライブラリと型定義のインストール

```bash
npm install @payjp/payjp-js payjp
npm install --save-dev @types/payjp typedef-payjp-js
```

### 2. 環境変数の設定

`.env.local` に公開鍵と秘密鍵を設定：

```
NEXT_PUBLIC_PAYJP_PUBLIC_KEY=pk_test_xxxxxxxxx
PAYJP_SECRET_KEY=sk_test_xxxxxxxxx
```

### 3. カード入力フォームの作成（app/checkout/page.tsx）

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";
import type PayjpJs from "typedef-payjp-js";

declare global {
  interface Window {
    Payjp?: (key: string) => PayjpJs.Payjp;
    __payjpInstance__?: PayjpJs.Payjp; // グローバルにインスタンス保持
  }
}

export default function CheckoutPage() {
  const [payjp, setPayjp] = useState<PayjpJs.Payjp | null>(null);
  const [ready, setReady] = useState(false);

  const numberRef = useRef<PayjpJs.PayjpElement | null>(null);
  const expiryRef = useRef<PayjpJs.PayjpElement | null>(null);
  const cvcRef = useRef<PayjpJs.PayjpElement | null>(null);

  // Payjpインスタンスを1回だけ作る
  useEffect(() => {
    if (typeof window === "undefined" || !window.Payjp) return;

    if (!window.__payjpInstance__) {
      const publicKey = "pk_test_token";
      window.__payjpInstance__ = window.Payjp(publicKey);
    }

    setPayjp(window.__payjpInstance__!);
    setReady(true);
  }, []);

  // カード要素を1回だけmountする
  useEffect(() => {
    if (!payjp || !ready) return;

    const elements = payjp.elements();

    if (!numberRef.current) {
      const numberElement = elements.create("cardNumber");
      numberElement.mount("#number-form");
      numberRef.current = numberElement;
    }

    if (!expiryRef.current) {
      const expiryElement = elements.create("cardExpiry");
      expiryElement.mount("#expiry-form");
      expiryRef.current = expiryElement;
    }

    if (!cvcRef.current) {
      const cvcElement = elements.create("cardCvc");
      cvcElement.mount("#cvc-form");
      cvcRef.current = cvcElement;
    }

    return () => {
      numberRef.current?.unmount();
      expiryRef.current?.unmount();
      cvcRef.current?.unmount();
      numberRef.current = null;
      expiryRef.current = null;
      cvcRef.current = null;
    };
  }, [payjp, ready]);

  const getToken = async () => {
    if (!payjp || !numberRef.current) {
      alert("カードフォームが初期化されていません");
      return;
    }

    const result = await payjp.createToken(numberRef.current);
    if (result.error) {
      alert(`エラー: ${result.error.message}`);
    } else {
      alert(`Token取得成功: ${result.id}`);
      // サーバーへの送信処理はここで書く
    }
  };

  return (
    <div className="max-w-md mx-auto p-4 bg-white rounded-lg shadow">
      <h2 className="text-xl font-bold mb-4">お支払い情報</h2>

      <div className="mb-4">
        <label className="block text-sm font-medium mb-1">カード番号</label>
        <div id="number-form" className="p-3 border rounded bg-gray-50" />
      </div>

      <div className="flex gap-4 mb-4">
        <div className="flex-1">
          <label className="block text-sm font-medium mb-1">有効期限</label>
          <div id="expiry-form" className="p-3 border rounded bg-gray-50" />
        </div>
        <div className="flex-1">
          <label className="block text-sm font-medium mb-1">セキュリティコード</label>
          <div id="cvc-form" className="p-3 border rounded bg-gray-50" />
        </div>
      </div>

      <button
        onClick={getToken}
        className="w-full p-3 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
      >
        ¥3,000を支払う
      </button>
    </div>
  );
}

```

## 完成したUI

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3291381/b5e946a2-b82b-4aea-a092-189ad994eb37.png)




## 🛡️ セキュリティと実装上の注意点

### 必ず実装すべき対策

- ✅ **環境変数管理**: 公開鍵・秘密鍵を`.env`ファイルで管理し、コードに直書きしない
- ✅ **サーバーサイド検証**: 金額・商品情報をサーバー側で再検証（フロントの値を信用しない）
- ✅ **HTTPS強制**: TLS1.2以上を使用し、すべての通信を暗号化
- ✅ **CORS設定**: 適切なオリジン制限でクロスサイトリクエストを保護
- ✅ **エラーハンドリング**: ユーザーに分かりやすいエラーメッセージを表示
- ✅ **認証と認可**: 支払い処理前にユーザーの認証状態を確認

### 推奨対策

- ✅ **不正検知**: 不自然な購入パターンの監視
- ✅ **ロギング**: 決済処理の適切なログ記録（個人情報は除く）
- ✅ **タイムアウト設定**: 長時間の処理を防止
- ✅ **Webhookの活用**: 非同期決済状態の更新に対応
- ✅ **依存パッケージ管理**: 定期的なアップデートと脆弱性チェック


## 📋 リソース、参考にした記事

- [Pay.jp 公式ドキュメント](https://pay.jp/docs/)
- [payjp.js リファレンス](https://pay.jp/docs/payjs)
- [Pay.jp API リファレンス](https://pay.jp/docs/api/)
- [payjp.js v2をReact+TypeScriptで使用する方法
](https://qiita.com/Yuumillar/items/70e91a66c48eb28855dc#%E5%9E%8B%E5%AE%9A%E7%BE%A9%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AEglobaldts%E3%81%AE%E7%94%A8%E6%84%8F)
- https://github.com/nemuvski/typedef-payjp-js