React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

stripePaymentIntentPaymentElementconfirmPaymentcustom checkout

Payment Intents (Custom Payment Flow)

Recipe

Create a PaymentIntent on the server to get a client secret, pass it to the Elements provider, render a PaymentElement for card input, and confirm the payment client-side.

Create the PaymentIntent server-side:

// app/actions/payment.ts
"use server";
 
import { stripe } from "@/lib/stripe";
 
export async function createPaymentIntent(amount: number) {
  const paymentIntent = await stripe.paymentIntents.create({
    amount, // in cents
    currency: "usd",
    automatic_payment_methods: { enabled: true },
  });
 
  return { clientSecret: paymentIntent.client_secret! };
}

Wrap the checkout form with Elements using the client secret:

// app/checkout/page.tsx
"use client";
 
import { useEffect, useState } from "react";
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { createPaymentIntent } from "@/app/actions/payment";
import { CheckoutForm } from "./checkout-form";
 
export default function CheckoutPage() {
  const [clientSecret, setClientSecret] = useState<string | null>(null);
 
  useEffect(() => {
    createPaymentIntent(2999).then(({ clientSecret }) => {
      setClientSecret(clientSecret);
    });
  }, []);
 
  if (!clientSecret) return <div>Loading...</div>;
 
  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm />
    </Elements>
  );
}

Build the checkout form:

// app/checkout/checkout-form.tsx
"use client";
 
import { useState, type FormEvent } from "react";
import {
  useStripe,
  useElements,
  PaymentElement,
} from "@stripe/react-stripe-js";
 
export function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [error, setError] = useState<string | null>(null);
  const [processing, setProcessing] = useState(false);
 
  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!stripe || !elements) return;
 
    setProcessing(true);
    setError(null);
 
    const { error: submitError } = await elements.submit();
    if (submitError) {
      setError(submitError.message ?? "Validation failed.");
      setProcessing(false);
      return;
    }
 
    const { error: confirmError } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/success`,
      },
    });
 
    if (confirmError) {
      setError(confirmError.message ?? "Payment failed.");
      setProcessing(false);
    }
    // If successful, Stripe redirects to return_url
  }
 
  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto p-6">
      <PaymentElement />
      {error && <p className="text-red-500 mt-4">{error}</p>}
      <button
        type="submit"
        disabled={!stripe || processing}
        className="w-full mt-6 bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
      >
        {processing ? "Processing..." : "Pay $29.99"}
      </button>
    </form>
  );
}

Working Example

// app/donate/page.tsx
"use client";
 
import { useEffect, useState } from "react";
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe-client";
import { createPaymentIntent } from "@/app/actions/payment";
import { CheckoutForm } from "@/app/checkout/checkout-form";
 
const DONATION_AMOUNTS = [500, 1000, 2500, 5000];
 
export default function DonatePage() {
  const [amount, setAmount] = useState(1000);
  const [clientSecret, setClientSecret] = useState<string | null>(null);
 
  useEffect(() => {
    setClientSecret(null);
    createPaymentIntent(amount).then(({ clientSecret }) => {
      setClientSecret(clientSecret);
    });
  }, [amount]);
 
  return (
    <div className="max-w-md mx-auto p-8">
      <h1 className="text-2xl font-bold mb-6">Make a Donation</h1>
 
      <div className="flex gap-2 mb-6">
        {DONATION_AMOUNTS.map((amt) => (
          <button
            key={amt}
            onClick={() => setAmount(amt)}
            className={`px-4 py-2 rounded ${
              amount === amt
                ? "bg-blue-600 text-white"
                : "bg-gray-200 text-gray-800"
            }`}
          >
            ${(amt / 100).toFixed(0)}
          </button>
        ))}
      </div>
 
      {clientSecret ? (
        <Elements
          stripe={stripePromise}
          options={{ clientSecret }}
          key={clientSecret}
        >
          <CheckoutForm />
        </Elements>
      ) : (
        <div>Loading payment form...</div>
      )}
    </div>
  );
}

Deep Dive

How It Works

  • A PaymentIntent represents a single payment attempt on Stripe's servers. It tracks the lifecycle from creation through confirmation to completion.
  • The client_secret is a token that lets the client-side code confirm the payment without exposing your secret key. It should never be logged or stored.
  • automatic_payment_methods: { enabled: true } tells Stripe to dynamically show the best payment methods for the customer's location and the transaction currency.
  • elements.submit() validates all form fields before confirmation. Always call it before confirmPayment to catch validation errors early.
  • stripe.confirmPayment sends the payment details directly from the browser to Stripe. Card data never passes through your server.
  • After successful confirmation, Stripe redirects to return_url. For 3D Secure, Stripe handles the authentication flow automatically.

Variations

Confirm without redirect (stay on page):

const { error, paymentIntent } = await stripe.confirmPayment({
  elements,
  redirect: "if_required",
});
 
if (error) {
  setError(error.message ?? "Payment failed.");
} else if (paymentIntent?.status === "succeeded") {
  // Show success message without redirecting
  setSuccess(true);
}

Add metadata to the PaymentIntent:

const paymentIntent = await stripe.paymentIntents.create({
  amount: 2999,
  currency: "usd",
  automatic_payment_methods: { enabled: true },
  metadata: {
    userId: user.id,
    productId: product.id,
  },
});

TypeScript Notes

  • stripe.paymentIntents.create returns Promise<Stripe.PaymentIntent>.
  • The confirmPayment return type includes { error?: StripeError; paymentIntent?: PaymentIntent }.
  • When using redirect: "if_required", always check both error and paymentIntent in the result.
import type { StripeError } from "@stripe/stripe-js";
 
function handleError(error: StripeError) {
  switch (error.type) {
    case "card_error":
      return error.message;
    case "validation_error":
      return "Please check your card details.";
    default:
      return "An unexpected error occurred.";
  }
}

Gotchas

  • Each PaymentIntent should be created once per checkout attempt. Do not create a new one every time the component re-renders -- use useEffect with stable dependencies.
  • The client_secret contains the PaymentIntent ID. Treat it as sensitive and do not expose it in URLs or logs.
  • When the amount changes, you need a new PaymentIntent. Use the key prop on Elements to force a full remount when the clientSecret changes.
  • confirmPayment with redirect: "always" (the default) will always redirect, even for card payments. Use redirect: "if_required" if you want to stay on the page.
  • The PaymentIntent amount is in the smallest currency unit. For USD, 2999 means $29.99. For JPY (zero-decimal currency), 2999 means 2999 yen.

Alternatives

ApproachProsCons
PaymentIntent + PaymentElementFull UI control, supports all payment methodsMore code than Checkout
Stripe CheckoutMinimal code, hosted UILimited customization
SetupIntentSaves card without chargingRequires separate charge step later
PaymentIntent + CardElementFine-grained card field controlOnly supports card payments

FAQs

What is a PaymentIntent and what role does the client_secret play?
  • A PaymentIntent represents a single payment attempt tracked on Stripe's servers
  • The client_secret is a token that lets client-side code confirm the payment without exposing your secret API key
  • It should never be logged, stored in a database, or exposed in URLs
Why should you call elements.submit() before stripe.confirmPayment()?

elements.submit() validates all form fields first. Calling it before confirmPayment catches validation errors (missing fields, invalid card format) early, providing a better user experience before attempting the actual charge.

What does automatic_payment_methods: { enabled: true } do?

It tells Stripe to dynamically select and display the best payment methods based on the customer's location, the transaction currency, and your Stripe Dashboard settings -- without you hardcoding specific methods.

Gotcha: What happens if you create a new PaymentIntent on every component re-render?

You end up with orphaned PaymentIntents on Stripe's servers and potentially confusing behavior. Always create the PaymentIntent in a useEffect with stable dependencies so it runs once per checkout attempt.

How do you stay on the page after payment instead of redirecting?
const { error, paymentIntent } = await stripe.confirmPayment({
  elements,
  redirect: "if_required",
});
if (paymentIntent?.status === "succeeded") {
  setSuccess(true);
}

Use redirect: "if_required" -- it only redirects when the payment method requires it (e.g., 3D Secure).

Why is the key prop needed on the Elements component when the clientSecret changes?

Changing the clientSecret prop alone does not reset the internal Stripe Elements state. Using key={clientSecret} forces React to unmount and remount the <Elements> component, creating a fresh instance tied to the new PaymentIntent.

How do you attach metadata to a PaymentIntent?
const paymentIntent = await stripe.paymentIntents.create({
  amount: 2999,
  currency: "usd",
  automatic_payment_methods: { enabled: true },
  metadata: { userId: user.id, productId: product.id },
});

Metadata is useful for correlating payments with your app's data in webhook handlers.

What is the difference between PaymentIntent amounts for USD vs JPY?

Amounts are in the smallest currency unit. For USD, 2999 = $29.99 (cents). For JPY (a zero-decimal currency), 2999 = 2999 yen. Always check whether the currency has decimal places.

TypeScript: How is the confirmPayment return type structured?
// Returns { error?: StripeError; paymentIntent?: PaymentIntent }
// When using redirect: "if_required", always check both fields
const { error, paymentIntent } = await stripe.confirmPayment({
  elements,
  redirect: "if_required",
});
TypeScript: How do you handle different StripeError types with type narrowing?
import type { StripeError } from "@stripe/stripe-js";
 
function handleError(error: StripeError) {
  switch (error.type) {
    case "card_error":
      return error.message;
    case "validation_error":
      return "Check your card details.";
    default:
      return "An unexpected error occurred.";
  }
}
Gotcha: What is the default redirect behavior of confirmPayment?

The default is redirect: "always", which redirects the user even for simple card payments that do not require 3D Secure. If you want to handle the result client-side, you must explicitly set redirect: "if_required".