A glowing checkout form on a laptop in a warm home office, with a gray-blue cat sleeping in the corner.

React Stripe.js: Wiring Up Payments Without the Pain

The Gray Cat
The Gray Cat
0 views

Taking payments in a React app is one of those tasks that looks trivial on a whiteboard and turns into a swamp the moment you start. The instant you let a credit card number touch your own server or bundle, you have signed up for PCI compliance audits that nobody enjoys. On top of that, modern customers expect Apple Pay, Google Pay, bank debits, buy-now-pay-later options, and a checkout that quietly adapts to whatever country they are in. React Stripe.js (@stripe/react-stripe-js) exists so you do not have to hand-wire any of that.

It is Stripe's official, first-party set of React components and hooks for Stripe.js and Stripe Elements. Rather than mounting Stripe's imperative DOM iframes yourself and juggling their lifecycle by hand, you drop in declarative components and read state through hooks. The card fields render inside secure Stripe-hosted iframes, so sensitive data never reaches your code. With roughly 6.7 million weekly downloads, it is the de facto standard for payments in the React ecosystem, and being MIT-licensed and maintained directly by Stripe means it is a safe default with effectively no maintenance risk.

Why Reach For It

The library does a surprising amount while staying out of your way. A few highlights worth knowing before you start:

  • The Payment Element. A single component that renders every payment method you have enabled in your dashboard, from cards to wallets to local methods, and adapts to the customer's location and currency automatically.
  • Tiny footprint. The wrapper is around 10 to 15 kB minified with zero runtime dependencies beyond prop-types. The heavyweight Stripe.js script is never bundled; it loads from Stripe's CDN at runtime, which keeps your bundle small and keeps card data out of your build entirely.
  • Wallets for free. Apple Pay, Google Pay, Link, PayPal, and Amazon Pay all come through the Payment Element or the dedicated Express Checkout button without extra integration work.
  • First-class TypeScript. Types ship in the package, and the library supports React 16.8 all the way through 19.
  • Theming via the Appearance API. Match your brand with a structured theme object instead of fighting iframe styling.

Getting It Installed

You always install two packages together. The React bindings live in @stripe/react-stripe-js, while the underlying JavaScript SDK and the loadStripe loader live in @stripe/stripe-js.

npm install @stripe/react-stripe-js @stripe/stripe-js

Or with yarn:

yarn add @stripe/react-stripe-js @stripe/stripe-js

One word of advice that will save you a headache later: pin both packages and upgrade them together. The major versions track each other closely, since react-stripe-js follows Stripe.js feature trains, and mismatched majors lead to peer-dependency warnings.

Setting The Stage

Everything starts with the <Elements> provider. It supplies the Stripe context to your component tree. You create the Stripe object yourself with loadStripe() and hand it in. The crucial detail here is that loadStripe() must be called outside your component, so it is not re-invoked on every render.

import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import type { StripeElementsOptions } from '@stripe/stripe-js';
import CheckoutForm from './CheckoutForm';

// Created once, outside the component, so it isn't recreated each render.
const stripePromise = loadStripe('pk_test_your_publishable_key');

export default function App() {
  const options: StripeElementsOptions = {
    mode: 'payment',
    amount: 1099,
    currency: 'usd',
    appearance: { theme: 'stripe' },
  };

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

Those options are doing real work. Here we use the deferred-intent shape, passing mode, amount, and currency so the form can render before you have created a PaymentIntent on the server. The alternative is to pass a clientSecret you generated up front. The appearance object hooks into the Appearance API for theming, and you can also supply fonts, locale, and more.

Building The Checkout Form

Inside the provider, the two hooks useStripe() and useElements() give you everything you need. The first returns the loaded Stripe instance, which is null until the script finishes loading. The second returns the Elements group so you can validate inputs and grab specific mounted elements.

The modern recommended flow uses deferred intents: validate on the client, create the PaymentIntent on your server, then confirm. Here it is end to end.

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

export default function CheckoutForm() {
  const stripe = useStripe();
  const elements = useElements();
  const [message, setMessage] = useState<string | null>(null);

  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault();
    if (!stripe || !elements) return; // not ready yet

    // 1. Validate and collect the customer's details on the client.
    const { error: submitError } = await elements.submit();
    if (submitError) {
      setMessage(submitError.message ?? 'Something went wrong.');
      return;
    }

    // 2. Ask YOUR server to create the PaymentIntent and return its secret.
    const res = await fetch('/create-intent', { method: 'POST' });
    const { clientSecret } = await res.json();

    // 3. Confirm the payment.
    const { error } = await stripe.confirmPayment({
      elements,
      clientSecret,
      confirmParams: { return_url: 'https://example.com/complete' },
    });

    if (error) {
      setMessage(error.message ?? 'Payment failed.');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <button disabled={!stripe}>Pay now</button>
      {message && <p role="alert">{message}</p>}
    </form>
  );
}

Two guards matter here. The early if (!stripe || !elements) return prevents you from submitting before the SDK is ready, and disabling the button on !stripe gives the user a visual cue. The single <PaymentElement /> is the star of the show: it renders cards, wallets, and whatever local methods you have enabled, all from one component.

Theming To Match Your Brand

A raw payment form rarely matches the rest of your product. The Appearance API lets you restyle the Payment Element through a structured object passed in the provider's options, rather than wrestling with iframe CSS you cannot reach.

const options: StripeElementsOptions = {
  mode: 'payment',
  amount: 4200,
  currency: 'eur',
  appearance: {
    theme: 'flat',
    variables: {
      colorPrimary: '#6366f1',
      colorBackground: '#0f172a',
      colorText: '#e2e8f0',
      borderRadius: '12px',
      fontFamily: 'Inter, system-ui, sans-serif',
    },
    rules: {
      '.Input:focus': {
        borderColor: '#6366f1',
        boxShadow: '0 0 0 1px #6366f1',
      },
    },
  },
};

You start from one of the built-in themes (stripe, night, flat), tweak high-level variables to set colors, radius, and typography, then drop down to rules for fine control over specific selectors. The form stays in its secure iframe; you simply describe how it should look.

One-Click Wallets With Express Checkout

If you want the prominent wallet buttons at the top of a checkout, the ExpressCheckoutElement renders them as a dedicated row. It surfaces Apple Pay, Google Pay, Link, PayPal, and Amazon Pay depending on what the customer's device and your account support, and it only shows buttons that are actually available.

import { ExpressCheckoutElement, useStripe, useElements } from '@stripe/react-stripe-js';
import type { StripeExpressCheckoutElementConfirmEvent } from '@stripe/stripe-js';

export default function ExpressCheckout() {
  const stripe = useStripe();
  const elements = useElements();

  const onConfirm = async (event: StripeExpressCheckoutElementConfirmEvent) => {
    if (!stripe || !elements) return;

    const { error: submitError } = await elements.submit();
    if (submitError) return;

    const res = await fetch('/create-intent', { method: 'POST' });
    const { clientSecret } = await res.json();

    const { error } = await stripe.confirmPayment({
      elements,
      clientSecret,
      confirmParams: { return_url: 'https://example.com/complete' },
    });

    if (error) {
      // surface error.message to the customer
    }
  };

  return <ExpressCheckoutElement onConfirm={onConfirm} />;
}

Pairing this row of wallet buttons above a regular Payment Element is a common pattern: shoppers who want one-tap checkout get it, and everyone else falls back to the full form below.

Dropping In Embedded Checkout

Sometimes you do not want to assemble a form at all. You just want Stripe's fully built, conversion-optimized checkout living inside your own page. That is what EmbeddedCheckoutProvider and EmbeddedCheckout are for. You create a Checkout Session on your server, hand its client secret to the provider, and render the UI.

import {
  EmbeddedCheckoutProvider,
  EmbeddedCheckout,
} from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { useCallback } from 'react';

const stripePromise = loadStripe('pk_test_your_publishable_key');

export default function HostedCheckout() {
  const fetchClientSecret = useCallback(async () => {
    const res = await fetch('/create-checkout-session', { method: 'POST' });
    const { clientSecret } = await res.json();
    return clientSecret;
  }, []);

  return (
    <EmbeddedCheckoutProvider
      stripe={stripePromise}
      options={{ fetchClientSecret }}
    >
      <EmbeddedCheckout />
    </EmbeddedCheckoutProvider>
  );
}

This is the lowest-effort path that still keeps the experience inside your domain. You give up granular UI control in exchange for Stripe handling the entire flow, including layout, validation, and method selection.

Card Element Versus Payment Element

You may run into older tutorials that reach for CardElement or the split CardNumberElement, CardExpiryElement, and CardCvcElement fields. These still exist and still work, but for nearly every new integration the Payment Element is the right call. It supports many payment methods from one component, brings wallet support along automatically, gets continuous improvements from Stripe, and themes through the Appearance API.

Reach for the card elements only when you genuinely need card-only collection with custom field-level layout, and be aware they use the older style.base and style.invalid styling API rather than the Appearance object. For most teams, the Payment Element is simpler, more capable, and more future-proof.

A Note On Migrating

If you are coming from the original react-stripe-elements, the deprecated predecessor, the move to @stripe/react-stripe-js is mostly mechanical. The old <StripeProvider> is gone; you instantiate Stripe yourself with loadStripe() and pass it to <Elements>. The injectStripe higher-order component is replaced by the useStripe() and useElements() hooks, or the <ElementsConsumer> render-prop component for class components. Element options become a single options object instead of scattered props, and you explicitly grab elements with elements.getElement(...) when calling methods like createToken().

Wrapping Up

React Stripe.js earns its place as the default payment library for React by getting the hard parts right and staying invisible for everything else. It keeps card data inside Stripe's iframes for PCI compliance, ships a tiny bundle because the real SDK loads from the CDN, and gives you a single Payment Element that quietly handles cards, wallets, and local methods across the world. The deferred-intent flow, the Appearance API, the Express Checkout row, and embedded Checkout cover the whole spectrum from build-it-yourself forms to fully hosted experiences. Remember the small rules, call loadStripe once outside render, guard your submit on !stripe || !elements, and pin your two Stripe packages together, and the rest is genuinely pleasant. For a task that used to mean compliance paperwork and fiddly iframe wrangling, that is a remarkably calm result.