Scientyfic World

How to Integrate Stripe with React: Checkout Sessions, Payment Element and Webhooks

To integrate Stripe with React, create a Checkout Session on your server with your secret key, send only its client secret to the browser, and render Stripe’s Payment Element inside...

Share:

Get an AI summary of this article

integrate stripe with react blog banner image containing stripe logo and a vector image with react logo

To integrate Stripe with React, create a Checkout Session on your server with your secret key, send only its client secret to the browser, and render Stripe’s Payment Element inside CheckoutElementsProvider from @stripe/react-stripe-js. When the customer clicks Pay, call checkout.confirm(); Stripe collects the card (or wallet) details in a secure iframe, handles 3D Secure, and redirects back to your return page. Fulfil the order from a webhook, not from the browser.

This guide uses the current packages: @stripe/react-stripe-js 7.0, @stripe/stripe-js 10.2 and the stripe Node.js library 23.0 with Express 5. The 2024 version of this post used CardElement and createPaymentMethod and then showed “Payment successful!”, even though creating a payment method doesn’t charge anything; that approach is outdated, and the flow below takes real payments. See “How I tested it” for exactly what I could and couldn’t verify without a Stripe account.

Which Stripe Integration Should You Use?

OptionWhat the customer seesEffortChoose it when
Stripe-hosted Checkout pageRedirect to a page on stripe.comLowestYou want payments working today and don’t mind leaving your site
Embedded CheckoutStripe’s full checkout form inside your pageLowYou want to stay on your site with minimal UI work
Checkout Sessions + Payment Element (this guide)Your own page and layout, with Stripe’s payment fieldsMediumYou want full control of the design but Stripe handling prices, tax, discounts and payment methods
Payment Intents + Payment ElementSame as aboveHigherYou need to manage the payment logic yourself

All four keep card numbers away from your servers, which keeps you in the lightest PCI compliance category. Stripe’s accept a payment guide compares them in detail. If you read older tutorials, note that the Checkout Session ui_mode values were renamed in the 2026 API: custom is now elements, embedded is embedded_page and hosted is hosted_page.

How the Payment Flow Works

  1. The React app asks your server to create a Checkout Session.
  2. The server creates it with your secret key, setting the products and prices itself, and returns the session’s client secret.
  3. React passes the client secret to CheckoutElementsProvider, which loads the session and renders the Payment Element.
  4. The customer enters payment details and clicks Pay; checkout.confirm() submits them to Stripe and handles any 3D Secure step.
  5. Stripe redirects to your return_url, where you show the result, and sends a checkout.session.completed webhook, where you fulfil the order.

Step 1: Get Your API Keys

In the Stripe Dashboard, switch to test mode (or a sandbox) and open Developers → API keys. You’ll get two keys:

  • Publishable key (pk_test_...): public, safe in React code.
  • Secret key (sk_test_...): only on the server. Never put it in a VITE_ or NEXT_PUBLIC_ variable, because those are bundled into the browser code (see why front-end secrets aren’t secret).
# client/.env
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...

# server/.env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CLIENT_URL=http://localhost:5173

Step 2: Install the Packages

# React app (Vite)
npm install @stripe/react-stripe-js @stripe/stripe-js

# Server
npm install stripe express

Create React App is deprecated, so use Vite for the client; with Vite, environment variables start with VITE_ instead of the old REACT_APP_.

Step 3: Create the Checkout Session on the Server

import express from 'express';
import Stripe from 'stripe';

// The secret key lives only on the server (never in React code)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
const CLIENT_URL = process.env.CLIENT_URL ?? 'http://localhost:5173';

// The webhook route needs the raw body to verify Stripe's signature,
// so register it before express.json()
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook signature verification failed: ${err.message}`);
  }
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    console.log('Fulfil order for session', session.id);   // mark the order paid in your database here
  }
  res.json({ received: true });
});

app.use(express.json());

// 1. Create a Checkout Session. Prices come from the server, not the browser.
app.post('/create-checkout-session', async (req, res) => {
  const session = await stripe.checkout.sessions.create({
    ui_mode: 'elements',
    mode: 'payment',
    line_items: [{
      price_data: { currency: 'usd', product_data: { name: 'Mechanical keyboard' }, unit_amount: 8900 },  // $89.00 in cents
      quantity: 1,
    }],
    return_url: `${CLIENT_URL}/complete?session_id={CHECKOUT_SESSION_ID}`,
  });
  res.json({ clientSecret: session.client_secret });
});

// 2. Let the return page check the result
app.get('/session-status', async (req, res) => {
  const session = await stripe.checkout.sessions.retrieve(req.query.session_id);
  res.json({ status: session.status, paymentStatus: session.payment_status, email: session.customer_details?.email ?? null });
});

app.listen(4242, () => console.log('Server on http://localhost:4242'));

Three details matter here:

  • Prices are set on the server. Never trust an amount sent from the browser, or anyone can pay $0.01. In a real shop, look up the products in your database or use Stripe Price IDs.
  • Amounts are in the smallest currency unit: 8900 means $89.00.
  • The webhook route uses express.raw() and is registered before express.json(), because signature verification needs the exact raw body. If you’re new to Express APIs, see how to build REST APIs with Node and Express.

Step 4: Load Stripe and Wrap the Form

import { useMemo } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { CheckoutElementsProvider } from '@stripe/react-stripe-js/checkout';
import CheckoutForm from './CheckoutForm';
import Complete from './Complete';

// Call loadStripe once, outside any component, so Stripe isn't reloaded on every render
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);

export default function App() {
  if (window.location.pathname === '/complete') return <Complete />;

  // Ask your server for a Checkout Session; only its client secret reaches the browser
  const clientSecret = useMemo(
    () => fetch('/create-checkout-session', { method: 'POST' })
      .then((res) => res.json())
      .then((data) => data.clientSecret),
    [],
  );

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

loadStripe loads Stripe.js from Stripe’s servers (required for PCI compliance; you can’t bundle it), and calling it outside the component makes sure it runs only once. The provider accepts the client secret as a promise, so the form can start loading while the request is in flight. During development, proxy /create-checkout-session to your Express server in vite.config.js, or call the full server URL and enable CORS.

Step 5: Build the Payment Form

import { useState } from 'react';
import { PaymentElement, useCheckoutElements } from '@stripe/react-stripe-js/checkout';

export default function CheckoutForm() {
  const checkoutState = useCheckoutElements();
  const [message, setMessage] = useState(null);
  const [submitting, setSubmitting] = useState(false);

  if (checkoutState.type === 'loading') return <p>Loading checkout...</p>;
  if (checkoutState.type === 'error') return <p role="alert">Checkout could not load: {checkoutState.error.message}</p>;

  const { checkout } = checkoutState;

  async function handleSubmit(e) {
    e.preventDefault();
    setSubmitting(true);
    // Confirms the payment; on success Stripe redirects to the Session's return_url
    const result = await checkout.confirm();
    if (result.type === 'error') setMessage(result.error.message);   // e.g. card declined
    setSubmitting(false);
  }

  return (
    <form onSubmit={handleSubmit}>
      <h2>Pay {checkout.total.total.amount}</h2>
      <PaymentElement />
      <button disabled={submitting}>{submitting ? 'Processing...' : 'Pay now'}</button>
      {message && <p role="alert">{message}</p>}
    </form>
  );
}

useCheckoutElements() returns loading, error or success. On success, checkout holds the session data (such as the formatted total) and methods like confirm(). The Payment Element shows cards plus whichever wallets and local payment methods you’ve enabled in the Dashboard, such as Apple Pay, Google Pay or Link, without code changes.

Step 6: Show the Result on the Return Page

import { useEffect, useState } from 'react';

export default function Complete() {
  const [status, setStatus] = useState(null);

  useEffect(() => {
    const sessionId = new URLSearchParams(window.location.search).get('session_id');
    fetch(`/session-status?session_id=${sessionId}`).then((r) => r.json()).then(setStatus);
  }, []);

  if (!status) return <p>Checking payment...</p>;
  if (status.status === 'complete') return <p>Payment received. A receipt was sent to {status.email}.</p>;
  return <p>Payment not completed (status: {status.status}). <a href="/">Try again</a></p>;
}

Use this page only to tell the customer what happened. Don’t fulfil orders here: customers can close the tab before the redirect, and anyone can open this URL. Fulfilment belongs in the webhook.

Step 7: Handle Webhooks

Stripe sends events such as checkout.session.completed to your /webhook route, and constructEvent verifies they really came from Stripe. For local development, the Stripe CLI forwards events to your machine and prints the signing secret to use:

stripe login
stripe listen --forward-to localhost:4242/webhook
stripe trigger checkout.session.completed

Make the handler idempotent (Stripe may deliver the same event more than once), return 200 quickly, and do slow work in the background. More in Stripe’s webhooks guide.

Testing Payments

In test mode, use Stripe’s test cards with any future expiry date and any CVC:

Card numberResult
4242 4242 4242 4242Succeeds
4000 0025 0000 3155Requires 3D Secure authentication
4000 0000 0000 9995Declined: insufficient funds

Test every path: success, decline (your form should show the error message), 3D Secure, the customer closing the tab before the redirect (the webhook should still fulfil the order) and a webhook with a bad signature (should get a 400).

How I Tested It

I don’t have a Stripe account connected to this blog, so I couldn’t run a real test-mode payment. Here is what I did verify:

CheckResult
Server: create a Checkout Session against stripe-mock (Stripe’s official API mock)Request with ui_mode: 'elements', price data and return URL accepted (200)
Server: /session-statusReturned { status: 'open', paymentStatus: 'unpaid', ... } from the mock
Webhook signed with the right secret200, and the “fulfil order” handler ran
Webhook signed with the wrong secret400 “Webhook signature verification failed”
React client: production build with Vite 8 and React 19.3Built without errors
React client in a headless browserLoaded Stripe.js, called the server, and showed the error state correctly when no valid client secret came back (the mock doesn’t issue real ones)
Return pageShowed “Payment not completed (status: open)” for an open session

What remains untested is the part that needs real Stripe test keys: the Payment Element rendering, entering a test card and the redirect after confirm(). Run through the test cards above before going live.

Going Live Checklist

  • Swap test keys for live keys in your server and client environment variables.
  • Create a live webhook endpoint in the Dashboard and use its signing secret.
  • Enable the payment methods you want under Settings → Payment methods.
  • Serve your site over HTTPS.
  • Make sure order fulfilment happens only in the webhook, and log failures.

Conclusion

A secure Stripe integration in React splits the work: your server creates the Checkout Session with the secret key and sets the prices, React renders the Payment Element inside CheckoutElementsProvider and calls checkout.confirm(), the return page shows the outcome, and a verified webhook fulfils the order. Keep the secret key and the amounts on the server, test with Stripe’s test cards and the Stripe CLI, and you’ll have a checkout that handles cards, wallets and 3D Secure without card data ever touching your servers. For other ways to call your backend from React, see how HTTP requests work.

FAQs

How do I integrate Stripe with React?

Install @stripe/react-stripe-js and @stripe/stripe-js. Create a Checkout Session on your server with the secret key, return its client secret, render the Payment Element inside CheckoutElementsProvider, call checkout.confirm() on submit, and fulfil the order from a checkout.session.completed webhook.

Do I need a backend to use Stripe with React?

Yes. Creating payments requires your secret key, which must never be in browser code. A small Node.js, serverless or framework API route is enough. Without any backend, use Stripe Payment Links instead.

Is CardElement deprecated?

CardElement still exists, but Stripe recommends the Payment Element, which supports cards, wallets and local payment methods in one component and handles authentication for you.

Is the Stripe publishable key safe to expose?

Yes. The publishable key is meant for client code. The secret key must stay on the server, in environment variables that are never bundled into the front end.

How do I test Stripe payments?

Use test mode keys and test cards such as 4242 4242 4242 4242 (success), 4000 0025 0000 3155 (3D Secure) and 4000 0000 0000 9995 (declined), with any future expiry date and any CVC. Use the Stripe CLI to forward and trigger webhooks locally.

Why should I fulfil orders from a webhook instead of the success page?

Customers can close the browser before being redirected, and anyone can open the success URL. Webhooks are sent by Stripe, are verified with a signature, and arrive even if the browser never returns.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts