iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
👛

[Next.js × Stripe] Building a Payment Form with Just 4 Files and Understanding How It Works

に公開

Introduction

When you hear "a payment form that works with just 4 files," it might sound like something you can finish with a simple copy-paste. However, behind the scenes, an astonishing number of architectural decisions are packed in: security boundaries via iframes, dynamic code loading from CDNs, and the separation of Server and Client Components.

In this article, we will build a minimal payment form using Next.js App Router and Stripe together, while digging into the "why" behind each step.

What you will gain from this article:

  • Implementation of a minimal payment form working with Next.js App Router and Stripe.
  • Understanding of the design rationale behind every file and line (PCI DSS, iframe isolation, leveraging Server Components).
  • Knowledge that goes beyond "copy-paste" to help you troubleshoot issues on your own.

Prerequisites:

  • Basic understanding of Next.js App Router (Server vs. Client Components, Route Handlers).
  • Fundamentals of TypeScript.
  • A Stripe account (in test mode).

File structure of the completed project:

lib/
├── stripe-server.ts       ← Server-side only (Secret Key)
└── stripe-client.ts       ← Client-side only (Publishable Key)
app/
├── checkout/
│   ├── page.tsx           ← Server Component (Create PaymentIntent)
│   └── PaymentForm.tsx    ← Client Component (Payment UI)
└── payment/
    └── complete/
        └── page.tsx       ← Result display page

Architecture diagram showing how 4 files are placed across 3 environments (Server, Browser, Stripe) and how they communicate.
Figure: 4-file structure and execution environment mapping. An overview of which environment each file runs in and which keys they use.

Overview ── The Minimal Composition and Processing Flow

Before diving into the implementation, let's take a bird's-eye view of the players and the processing flow.

There are four players in this minimal composition: the Browser (Client Component), the Next.js Server (Server Component), Stripe.js (an iframe inside the browser), and the Stripe API.

Figure: Payment form processing flow. Card information is handled within an iframe and does not pass through the Next.js server.

The core of this architecture is "minimizing developer implementation burden while maintaining PCI DSS compliance." Since card numbers never pass through the Next.js server, you remain eligible for the simplest PCI DSS self-assessment questionnaire (SAQ A).

Step 1 ── Server-side Stripe Initialization (stripe-server.ts)

First, we create the initialization file for calling the Stripe API on the server side.

// lib/stripe-server.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2026-01-28.clover',
});

It is only 3 lines, but this stripe object is built with mechanisms that support production environments.

Iceberg model diagram showing 3 functions automatically executed behind the 3 lines of stripe-node initialization code.
Figure: The stripe-node iceberg model. Below the 3 lines of code on the surface, the SDK automatically handles retries, idempotency keys, and telemetry.

What happens inside stripe-node:

  • Automatic Retries: Retries with exponential backoff are automatically executed for transient network issues or temporary Stripe-side failures (409/429/500/503). By default, it retries up to once (since v13).
  • Automatic Idempotency Key: The SDK automatically adds an idempotency key to POST requests. This prevents the same request from being processed multiple times during retries.
  • Telemetry: Sends latency information for the previous request to Stripe (can be opted out via telemetry: false).

It is critical that this file is only used in Server Components and Route Handlers. STRIPE_SECRET_KEY is, as the name implies, a secret; if exposed to the client, your entire API could be compromised.

// ❌ Adding NEXT_PUBLIC_ includes it in the client bundle, causing a leak
// NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_test_...

// ✅ No prefix → Accessible only on the server side
// STRIPE_SECRET_KEY=sk_test_...

Step 2 ── Client-side Stripe Initialization (stripe-client.ts)

Next, we create a file to load Stripe.js on the browser side.

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

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

Although we are calling loadStripe() here, what happens under the hood is quite different from typical npm package behavior.

Flow diagram showing the 3-stage process where loadStripe() loads a CDN script from an npm package and generates an iframe inside the browser.
Figure: Behind the scenes of loadStripe(). The npm package (~2KB) is just a lightweight wrapper that loads a CDN script; the actual payment code is loaded from a Stripe-managed CDN and runs inside an iframe.

@stripe/stripe-js is just a lightweight wrapper. The real substance lies in the script loaded dynamically via CDN from https://js.stripe.com/v3/. Simplified, the process looks like this:

// Simplified internals of loadStripe()...
const script = document.createElement('script');
script.src = 'https://js.stripe.com/v3/';
document.head.appendChild(script);

Why load dynamically from a CDN? The answer is PCI DSS. If you include code that handles card information in your own bundle, your entire application falls under the scope of rigorous PCI DSS audits. By isolating it via CDN, the payment code remains under Stripe's management, minimizing the developer's PCI DSS scope.

Stripe.js, once loaded from the CDN, generates an iframe to serve as the card input field. This iframe is the cornerstone of the payment form's security. Due to the browser's Same-Origin Policy, JavaScript on your-app.com cannot access the DOM inside the js.stripe.com iframe. Even if your site has an XSS vulnerability, the card numbers inside the iframe remain safe.

Communication between the parent page and the iframe is performed entirely via the postMessage API. This is why you cannot use standard CSS to customize the style and must instead go through Stripe's proprietary API options (appearance).

Step 3 ── Create a PaymentIntent with Server Components (page.tsx)

Now, we move on to an implementation that leverages the unique characteristics of the Next.js App Router.

// app/checkout/page.tsx
import { stripe } from '@/lib/stripe-server';
import { PaymentForm } from './PaymentForm';

export default async function CheckoutPage() {
  const paymentIntent = await stripe.paymentIntents.create({
    amount: 1000,
    currency: 'jpy',
    automatic_payment_methods: { enabled: true },
    metadata: {
      order_id: 'demo_order_001',
    },
  });

  return (
    <main>
      <h1>Checkout</h1>
      <PaymentForm clientSecret={paymentIntent.client_secret!} />
    </main>
  );
}

Data flow where the Server Component creates a PaymentIntent and passes only the client_secret to the Client Component
Figure: Data flow across the server/client boundary. Only the client_secret crosses the boundary, while the secret key and amount remain on the server side.

This code reflects three critical design decisions:

1. What is a PaymentIntent?

A PaymentIntent is an object that represents an "intent to pay." It manages the amount, currency, and status, and holds an authentication token called client_secret. The browser side uses this client_secret to "confirm" the payment.

2. Why create it in a Server Component?

Using stripe inside a Server Component ensures that the secret key is never exposed to the client. More importantly, it allows you to determine the amount on the server side.

// ❌ Accepting the amount from the frontend → Risk of tampering
const { amount } = await request.json();
await stripe.paymentIntents.create({ amount, currency: 'jpy' });

// ✅ Determining the amount on the server → Secure
const cart = await getCartFromDatabase(cartId);
await stripe.paymentIntents.create({ amount: cart.totalAmount, currency: 'jpy' });

If you allow the amount to be set by the frontend, a user could manipulate the value in browser developer tools to pay an arbitrary amount. Always keep the authority to determine the amount on the server.

3. automatic_payment_methods: { enabled: true }

With this setting, payment methods enabled in the Stripe Dashboard are automatically displayed in the Payment Element. You can add or remove payment methods (such as convenience store payments or bank transfers) without changing any code.

Step 4 ── Building the Payment UI with Payment Element (PaymentForm.tsx)

Finally, let's implement the payment form.

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

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

function CheckoutFormInner() {
  const stripe = useStripe();
  const elements = useElements();
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const [isProcessing, setIsProcessing] = useState(false);

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

    setIsProcessing(true);
    setErrorMessage(null);

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

    // This code only reaches if an error is detected before redirection
    if (error) {
      if (error.type === 'card_error' || error.type === 'validation_error') {
        setErrorMessage(error.message ?? 'Please check your input.');
      } else {
        setErrorMessage('An unexpected error occurred.');
      }
    }

    setIsProcessing(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      {errorMessage && (
        <div role="alert" style={{ color: '#df1b41', marginTop: 8 }}>
          {errorMessage}
        </div>
      )}
      <button type="submit" disabled={!stripe || isProcessing}>
        {isProcessing ? 'Processing...' : 'Pay'}
      </button>
    </form>
  );
}

export function PaymentForm({ clientSecret }: { clientSecret: string }) {
  return (
    <Elements
      stripe={stripePromise}
      options={{ clientSecret, appearance: { theme: 'stripe' } }}
    >
      <CheckoutFormInner />
    </Elements>
  );
}

This file is packed with points that require explanation. Let's look at them one by one.

Payment Element vs. Card Element

Stripe Elements offers multiple element types, but as of 2026, the Payment Element is the only choice for new projects. Card Element is treated as legacy and no new features are being added to it.

Comparison Item Card Element (Legacy) Payment Element (Recommended)
Supported Payment Methods Card only 100+ (Cards, Apple Pay, Convenience store, etc.)
Adding Payment Methods Requires code changes Configurable via Stripe Dashboard only
3D Secure Handling Basic Enhanced
Accessibility Limited Optimized

What happens with confirmPayment()?

Now that we have placed the Payment Element, let's look at the internals of the confirmation process. Calling confirmPayment() branches into three result paths:

The three result paths of confirmPayment(): Redirection on success, redirection after authentication for 3DS, and returning an error object without redirecting on error
Figure: The three result paths for confirmPayment(). Success and 3DS authentication redirect to the return_url, while errors return the {error} object without redirecting.

Internally, the following flow is executed:

Figure: Internal flow of confirmPayment(). Card numbers are tokenized within the iframe, and raw data is never exposed outside.

There is a crucial design decision here: confirmPayment() does not reject the Promise. Card rejections and input errors are returned as the {error} property.

// ✅ Stripe pattern: Check via property instead of try/catch
const { error } = await stripe.confirmPayment({
  elements,
  confirmParams: { return_url: '...' },
});
if (error) { /* Handle error */ }
// Success redirects to return_url, so this code is not reached

If you specify redirect: 'if_required', redirection is suppressed and you can retrieve the { paymentIntent }. This is the pattern to use if you want to avoid page transitions in an SPA.

Why this design? Stripe positions "card rejection" as "one of the expected outcomes, not an exception." Insufficient funds or incorrect CVCs are everyday occurrences for users and are not "anomalies" that should be captured by try/catch.

You can safely display the card_error and validation_error messages directly to the user as they are already localized by Stripe. Conversely, you do not need to show the details of invalid_request_error or api_error to the user; instead, display a generic message.

About 3D Secure

As of March 2025, EMV 3D Secure is mandatory for all EC merchants in Japan. If you are using the Payment Element, confirmPayment() automatically handles the 3DS authentication flow. Developers do not need to write any additional code.

Step 5 ── Implementing the Result Page

When confirmPayment() succeeds, it redirects to the return_url. At this time, Stripe automatically appends query parameters:

/payment/complete
  ?payment_intent=pi_3ABC...
  &payment_intent_client_secret=pi_3ABC..._secret_XYZ...
  &redirect_status=succeeded

On the redirection destination page, use these parameters to verify and display the payment result.

// app/payment/complete/page.tsx
import { stripe } from '@/lib/stripe-server';

type Props = {
  searchParams: Promise<{
    payment_intent?: string;
    redirect_status?: string;
  }>;
};

export default async function PaymentCompletePage({ searchParams }: Props) {
  const params = await searchParams;
  const paymentIntentId = params.payment_intent;

  if (!paymentIntentId) {
    return <p>Invalid access.</p>;
  }

  const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);

  switch (paymentIntent.status) {
    case 'succeeded':
      return <h1>Payment completed successfully</h1>;
    case 'processing':
      return <h1>Payment is being processed. Please wait.</h1>;
    case 'requires_payment_method':
      return <h1>Payment failed. Please try a different card.</h1>;
    default:
      return <h1>Unexpected state.</h1>;
  }
}

Do not judge the success or failure based solely on the redirect_status query parameter. URL parameters can be tampered with by the user. Always use stripe.paymentIntents.retrieve() to fetch the latest status from the server side.

Even if 3D Secure authentication is performed, the user is redirected to this return_url after completion. Whether the authentication method is an iframe modal or a full-page redirect depends on the issuing bank's implementation, but this page will function correctly in both patterns.

Operation Checks and Common Pitfalls

Testing with Test Cards

In Stripe's test mode, you can test various scenarios using the following card numbers. Enter any 3 digits for the CVC and any future date for the expiration.

Card Number Scenario
4242 4242 4242 4242 Success
4000 0000 0000 3220 Requires 3D Secure authentication
4000 0000 0000 9995 Declined due to insufficient funds

Pitfall 1: Calling loadStripe() inside a component

// ❌ loadStripe() is called on every re-render
function PaymentForm() {
  const stripePromise = loadStripe('pk_test_...');
  return <Elements stripe={stripePromise}>...</Elements>;
}

// ✅ Call it once at the module scope
const stripePromise = loadStripe('pk_test_...');
function PaymentForm() {
  return <Elements stripe={stripePromise}>...</Elements>;
}

loadStripe() is a function that triggers the loading of the CDN script. If called inside a component, a loading check runs every time it renders. Caching the Promise at the module scope is the correct pattern.

Pitfall 2: Including client_secret in the URL

// ❌ Included in the URL, risking exposure in browser history and logs
<Link href={`/checkout?secret=${clientSecret}`}>

// ✅ Pass it via props from a Server Component
<PaymentForm clientSecret={paymentIntent.client_secret!} />

If you have the client_secret, you can confirm a PaymentIntent. Including it in the URL risks exposure in browser history or server access logs, allowing unauthorized access. Passing it directly via props from a Server Component is secure.

Pitfall 3: Sending the amount from the frontend

As explained in Step 3, the amount must be determined on the server side. Accepting an amount from the frontend and passing it to paymentIntents.create() is a dangerous pattern, as it can be altered to any amount using browser developer tools.

Send only "what to buy" (e.g., product ID or cart ID) from the frontend, and retrieve the "how much" from the database on the server side.

Conclusion: Beyond the Minimal Configuration

In this article, we built a payment form with a minimal configuration of 4 files plus a results page, and delved into what happens under the hood at each step.

We explored these five aspects:

  • stripe-server.ts: The stripe-node automatic retry and idempotency key mechanism, and why keeping the secret key on the server is critical.
  • stripe-client.ts: How loadStripe() dynamically loads scripts from a CDN and creates a security boundary via iframe.
  • page.tsx: Designing the architecture where the Server Component creates the PaymentIntent, ensuring the server retains the authority to determine the amount.
  • PaymentForm.tsx: The internal flow of the Payment Element and the design philosophy behind confirmPayment() not rejecting.
  • Results Page: The importance of verifying status on the server side.

However, this minimal configuration is just the entrance to a full-fledged payment system. There are still many challenges to moving to a production environment:

  • The full lifecycle of a PaymentIntent (behavior as a state machine).
  • Confirming payment completion on the server side via Webhooks (ensuring orders are finalized even if the browser tab is closed).
  • Webhook idempotency design (preventing double-processing if the same event is received twice).
  • Managing Customer objects and reusing card information.
  • Off-session billing (billing stored cards without user interaction).
  • Production operation patterns (error handling strategies, monitoring, testing).

For those who want to systematically learn about these topics, from the full state transition of PaymentIntents to Webhook idempotency design and production patterns, my book "Payment System Dismantled and Reconstructed" covers these in detail.

Discussion