Stripeの決済フォームをサイトやアプリに埋め込む際、stripe.jsライブラリのElementsコンポーネントをProviderとして利用します。このコンポーネントにはStripeのフォームを埋め込む機能とは別にもう1つ役割がありますが、これを利用するにはServer ComponentではなくClient Componentとなる範囲が増えやすくなります。
この記事では、Stripe.jsをNext.jsで使う際のポイントについてまとめます。
Stripe.jsとNext.jsで発生するジレンマ
Stripe.jsを利用するためには、ElementsというProviderコンポーネントでラップします。このコンポーネントはStripe.jsの初期化などを担っており、このコンポーネントが描画された時点でStripe.jsのSDKが読み込まれます。また、このコンポーネントはContextを使う関係上、use clientによるクライアントコンポーネントとして扱われます。そのため、Server Componentをなるべく活用したいケースでは、決済フォームを表示する部分のみでこのProviderを使うことになります。
しかしこの方法では、Stripe.jsがもつもう1つの役割に制限が生まれます。それはStripe Radar の不正検知です。サイト上での顧客の動作・シグナルをできるだけ収集させることで、不正検知の精度を高めます。誤検知によるブロックやカゴ落ちの抑止と、不正な注文の防止を高精度で行うため、できる限りすべてのページで、Stripe.jsを読み込ませる必要があります。
Elementsを読み込むと、Client Componentの範囲が広がる。しかしElementsを利用するページを限定すると、不正検知の精度が落ちる。Next.jsとStripe.jsの組み合わせにはこのようなジレンマがあります。
このジレンマを解決するには、Stripeが提供するSDKやStripe.jsの仕組みを理解する必要があります。
stripe.js と <Elements> Provider は別物
このジレンマを解くヒントは、@stripe/stripe-js パッケージの設計です。これが提供するloadStripe 関数には、2つの異なる責務があります。
1つ目は、Advanced fraud detection signals の収集です。デバイスの特性やユーザーの行動パターンを m.stripe.com に送信し、Stripe Radar の不正検知を支えるシグナルを生成します。
2つ目は、Elements API のインスタンス生成です。<PaymentElement> や useStripe() / useElements() フックを動作させるための Stripe オブジェクトを作ります。
React SDK のガイドに従って <Elements> Provider を配置すると、この2つがセットで Provider の内側に閉じ込められます。「Provider を広げる='use client' が広がる」「Provider を狭める=シグナル収集範囲が狭まる」という偽のトレードオフが生まれる理由はここにあります。
しかし、1つ目の責務——シグナルの収集——は、stripe.js のスクリプトがページに読み込まれているだけで機能します。<Elements> Provider のマウントは不要です。つまり、スクリプトの読み込みと Provider の配置は、完全に分離できます。
Radar が見ているもの
Stripe Radar は、stripe.js から送られるシグナルをもとに不正な取引を検知します。公式ドキュメントによると、Advanced risk factors(デバイス特性と行動指標)はモデルの精度を 36% 改善します。IP アドレス(12%)やカスタマーメール(11%)を大きく上回る、最重要シグナルです。
収集されるシグナルは2種類あります。
デバイス特性は、ブラウザ・画面・デバイスの情報です。画面解像度が極端に小さいのに User-Agent はデスクトップというような、通常の利用者ではありえない組み合わせを検出します。
行動指標は、実際のユーザー操作の特徴です。ページ間の遷移速度、マウスの動き、カード番号が手入力かコピペかといった情報から、ボットと人間を見分けます。
ここで重要なのは、これらのシグナルがセッション全体から収集されるという点です。公式ドキュメントには、チェックアウトページだけでなく購買体験のすべてのページで stripe.js を読み込むことを推奨すると明記されています。商品ページの滞在時間がゼロなのにチェックアウトに直行する、といった不自然な行動パターンを検出できるのは、全ページにシグナル収集が行き渡っているからです。
3ファイルで分離した実装を行う
では、この2つを両立させる実装をみてみましょう。やるべきことは、stripe.js のスクリプト読み込みと <Elements> Provider の配置を、独立した2つの関心事として扱うことです。実装は3つのファイルで完結します。
1. Stripe インスタンスの singleton を作る
まずはloadStripeによる読み込みです。これはSingletonとして別ファイルで実装、読み込みさせます。
// lib/stripe.ts
import { loadStripe } from '@stripe/stripe-js';
export const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
@stripe/stripe-js から import すると、 stripe.js の <script> タグが即座に挿入されます。バージョン管理は npm パッケージが担っており、@stripe/stripe-js@6.x であれば acacia、それ以降の各メジャーバージョンも対応する Stripe.js バージョンに固定されています。
loadStripe はモジュールスコープで1度だけ呼ばれ、返り値の Promise は singleton として共有されます。複数のコンポーネントからこのモジュールを import しても、Stripe インスタンスは1つです。
2. 薄い Client Component を root layout に置く
続いて作成したSingletonを読み込むClient Componentを作りましょう。
// app/stripe-loader.tsx
'use client';
// 副作用 import のみ。stripe.js の script タグが挿入されます
import '@stripe/stripe-js';
export default function StripeLoader() {
return null;
}
StripeLoader は null を返すだけのコンポーネントです。Stripe.jsのtagを追加する部分だけをこのコンポーネントに担わせます。これをapp/layout.tsxで読み込ませましょう。
// app/layout.tsx — Server Component のまま
import StripeLoader from './stripe-loader';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
<StripeLoader />
{children}
</body>
</html>
);
}
この構成にすることで、サイト内のすべてのページで stripe.js が読み込まれ、Radar のシグナル収集が有効になります。
3. チェックアウトのみに Elements Provider を配置する
<Elements> Provider は、<PaymentElement> を実際にレンダリングする場所にだけ配置します。
// app/checkout/payment-form.tsx
'use client';
import { Elements } from '@stripe/react-stripe-js';
import { stripePromise } from '@/lib/stripe'; // singleton を共有
export function PaymentForm({
clientSecret,
}: {
clientSecret: string;
}) {
return (
<Elements
stripe={stripePromise}
options={{ clientSecret }}
>
<CheckoutFormInner />
</Elements>
);
}
// app/checkout/page.tsx — Server Component
import { PaymentForm } from './payment-form';
import { createPaymentIntent } from '@/app/actions/payment';
export default async function CheckoutPage() {
const { clientSecret } = await createPaymentIntent();
return (
<main>
<h1>お支払い</h1>
<PaymentForm clientSecret={clientSecret} />
</main>
);
}
lib/stripe.ts の stripePromise を StripeLoader と PaymentForm の両方で共有しています。stripe.js の script タグは1つだけ挿入され、Stripe インスタンスも1つです。二重読み込みもバージョン不整合も起きません。
CheckoutPage 自体は Server Component のままです。'use client' の境界は PaymentForm の内側に閉じています。
まとめ
Stripe.jsには決済フォームの提供以外にもう1つの役割があります。しかしこれが紹介されているページはあまり多くありません。「全ページに stripe.js を入れろ」という記述は、Optimize risk factors と Advanced fraud detection のページにあります。一方、React SDK の組み込みガイドには、Radar のシグナル収集範囲に関する記述は出てきません。
そのため、Next.js側の仕様に合わせた実装に寄せることで意図しない不正検知の精度低下を引き起こします。
覚えておくべきことは2点です。stripe.js はスクリプトとして読み込むだけで Radar のシグナルを収集します。そしてバージョン管理は npm パッケージに委ね、副作用 import で全ページに行き渡らせます。<Elements> Provider は、決済 UI を表示する場所にだけ置けば十分です。