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?
| Option | What the customer sees | Effort | Choose it when |
|---|---|---|---|
| Stripe-hosted Checkout page | Redirect to a page on stripe.com | Lowest | You want payments working today and don’t mind leaving your site |
| Embedded Checkout | Stripe’s full checkout form inside your page | Low | You 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 fields | Medium | You want full control of the design but Stripe handling prices, tax, discounts and payment methods |
| Payment Intents + Payment Element | Same as above | Higher | You 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
- The React app asks your server to create a Checkout Session.
- The server creates it with your secret key, setting the products and prices itself, and returns the session’s client secret.
- React passes the client secret to
CheckoutElementsProvider, which loads the session and renders the Payment Element. - The customer enters payment details and clicks Pay;
checkout.confirm()submits them to Stripe and handles any 3D Secure step. - Stripe redirects to your
return_url, where you show the result, and sends acheckout.session.completedwebhook, 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 aVITE_orNEXT_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:5173Step 2: Install the Packages
# React app (Vite)
npm install @stripe/react-stripe-js @stripe/stripe-js
# Server
npm install stripe expressCreate 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:
8900means $89.00. - The webhook route uses
express.raw()and is registered beforeexpress.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.completedMake 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 number | Result |
|---|---|
| 4242 4242 4242 4242 | Succeeds |
| 4000 0025 0000 3155 | Requires 3D Secure authentication |
| 4000 0000 0000 9995 | Declined: 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:
| Check | Result |
|---|---|
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-status | Returned { status: 'open', paymentStatus: 'unpaid', ... } from the mock |
| Webhook signed with the right secret | 200, and the “fulfil order” handler ran |
| Webhook signed with the wrong secret | 400 “Webhook signature verification failed” |
| React client: production build with Vite 8 and React 19.3 | Built without errors |
| React client in a headless browser | Loaded 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 page | Showed “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.








