Introduction
Recently, I’ve been learning the basics of React since it’s a central part of Stripe Apps. After taking Brian Holt’s excellent course on frontendmasters “Complete intro to React V7” I decided to see if I could use those fundamentals to build a site to accept a payment using React, React Stripe, and the Payment Element. In order to try to learn as much as I could about the tooling, I opted to not use anything other than Vite’s (a frontend development and build tool built by the creator of Vue) scaffolding tool to create a basic project and go from there.
Follow along
The completed demo is available on GitHub if you would like to clone the project.
What you'll learn
In this post you’ll learn how to use the Payment Element with React to accept payments. The Payment Element is an embeddable UI component that lets you accept more than 18 (and growing!) payment methods with a single integration. To achieve this we’ll leverage Vite, Fastify, and React Stripe.
High level overview
In this end to end integration we’ll:
- Start a brand new Vite project
- Create a Checkout component to initialize a payment flow
- Create a simple Node backend to return a publishable key and create a Payment Intent
- Run both the Vite server and the Node server concurrently
- Create a Checkout Form component to render the Payment Element
- Confirm the Payment Intent
Versioning
The versions of all dependencies at the time of writing can be seen in the package.json
file in the repo. Since I’m a beginner with React, I took the chance to install the most recent versions and everything worked fine, but I understand that getting version compatibility right can be a challenge.
Vite
Vite is a development server and build tool that supports different frontend frameworks like React, Vue and Svelte. It supports hot reloading code while developing and can also build your code for production. I’ll just be using Vite to stand up a development project. I used Parcel (which just works out of the box) during my first forays into React, but Vite is an alternative that works very well and is also used on Glitch where I’ll host my final project.
Prerequisites
For this demo, we’ll use Node version 16.10.0
, and npm version 7.24.0
. You also need a basic understanding of React components, useState, useEffect, and a Stripe account which you can sign up for here.
Starting a new project
npm create vite@latest
When prompted, I selected the default project name of vite-project
and used the standard React framework and variant.
Now we’ll cd
into the project and we’ll specify that we don’t want to use React 18, but rather 17. At the time of writing, React 18 hasn’t been fully GA’d and also there are some new changes with useEffect
and StrictMode
that I’ll avoid for this demo.
In package.json
change react
react-dom
@types/react
and @types/react-dom
packages to ^17.0.2
.
"react": "^17.0.2",
"react-dom": "^17.0.2"
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2"
Now we’ll install dependencies and run the dev server.
npm install
npm run dev
At this point, the code will actually not fully work because the boilerplate code that Vite generated is for React 18 and not React 17 which we just specified. If you navigate to http://localhost:3000/ (the standard port for Vite), in fact we'll see this error:
[plugin:vite:import-analysis] Failed to resolve import "react-dom/client" from "src/main.jsx". Does the file exist?
The file that we need to fix is main.jsx
. Running this command will nevertheless start a local development server on port 3000, but again we need to make some fixes before we’ll see anything.
We’ll replace the code in main.jsx
with this variant:
import React from "react";
import { render } from "react-dom";
import App from "./App.jsx";
const container = document.getElementById("root");
render(
<React.StrictMode>
<App />
</React.StrictMode>,
container
);
Not a huge amount has changed, but let’s review the differences. Firstly, on line two we import the render
function from react-dom
instead of importing ReactDOM
from react-dom/client
. Secondly, we use that render
function to render the App component rather than using createRoot
from the new React 18 root API.
The site should now hot reload and we see our friendly React page with the counter. If not, restart the server and reload the page.
Adding a Checkout component
Let’s jump into the App
component and start building our own checkout. Our App
will render our Checkout component, so we’ll remove the boilerplate code and replace it with this:
import Checkout from "./Checkout.jsx";
function App() {
return <Checkout />;
}
export default App;
But, we’ll receive an error since we haven’t created the Checkout component yet.
So, let’s create that! Create Checkout.jsx
in the src
folder. Before we write our imports, let’s install the required Stripe dependencies:
npm install --save @stripe/react-stripe-js @stripe/stripe-js
We’ll also install axios
to help with making calls to a backend server:
npm install --save axios
Now let’s import the things that we need in the Checkout component:
import { useEffect, useState } from "react";
import axios from "axios";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
Let’s discuss these imports and their uses:
- We will need
useEffect
when the component first renders, to fetch data from a backend API with axios, specifically to create a Payment Intent - We’ll leverage
useState
to set a client secret from the Payment Intent and a booleanloading
state - We’ll use the Elements provider to render the Payment Element on our CheckoutForm (we’ll code this later)
- And we’ll import
loadStripe
to actually load Stripe.js on our page
Let’s start with a React function component that just renders a h1
in a div
.
import { useEffect, useState } from "react";
import axios from "axios";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
const Checkout = () => {
return (
<div>
<h1>Checkout</h1>
</div>
);
};
export default Checkout;
Next, we’ll set up our state handling for a client secret and a loading
boolean value using useState
:
import { useEffect, useState } from "react";
import axios from "axios";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
const Checkout = () => {
const [clientSecretSettings, setClientSecretSettings] = useState({
clientSecret: "",
loading: true,
});
return (
<div>
<h1>Checkout</h1>
</div>
);
};
export default Checkout;
Setting up a backend
To setup a simple backend to interact with the Stripe API we’ll perform the following:
- Install the require dependencies, in this case
dotenv
,fastify
andstripe
- Setup our keys in a
.env
file (used by dotenv) - Create a
server.js
for two backend routes - Configure Vite to proxy calls to the backend
- Run both the Vite development server and the Node backend at the same time using the
concurrently
package
We’ll need to create a simple backend that will return the Stripe publishable key to the frontend and call the Stripe API to create a Payment Intent. For this demo, we’ll use Fastify as a lightweight server and configure our Stripe keys using dotenv
. Let’s install those dependencies:
npm install --save dotenv fastify stripe
In the root of the project, we’’ll create a file named .env
and configure the Stripe test secret key and test publishable key. Your test keys can be found in the dashboard in the Developers section under API keys. They begin with sk_test
and pk_test
respectively.
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
Also in the root of the project we’ll create a server.js
file for our backend code.
require("dotenv").config();
// Require the framework and instantiate it
const fastify = require("fastify")({ logger: true });
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
// Fetch the publishable key to initialize Stripe.js
fastify.get("/publishable-key", () => {
return { publishable_key: process.env.STRIPE_PUBLISHABLE_KEY };
});
// Create a payment intent and return its client secret
fastify.post("/create-payment-intent", async () => {
const paymentIntent = await stripe.paymentIntents.create({
amount: 1099,
currency: "eur",
payment_method_types: ["bancontact", "card"],
});
return { client_secret: paymentIntent.client_secret };
});
// Run the server
const start = async () => {
try {
await fastify.listen(5252);
console.log("Server listening ... ");
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Let’s dissect this backend code. First, we use dotenv
to configure the Stripe API keys that we included in the .env
file earlier. Then we instantiate both Fastify and Stripe. We need two routes for this demo, one GET route to send the publishable key to the frontend for Stripe.js, and one POST route to create a Payment Intent, and return the client secret to the frontend for the Payment Element. Our Payment Intent will be created to allow payment with cards and Bancontact. Finally, we start the server listening on port 5252.
Configuring Vite to proxy calls to our backend
When starting Vite using the npm run dev
script, it listens on port 3000
by default to serve the frontend. When developing, we’ll want our React code to make API calls to the Node server running on port 5252
as described above. Vite allows us to proxy those calls using some simple configuration. In this case, when making calls to our backend, we’ll prefix the paths with /api
. Then we’ll configure Vite to proxy any calls that begin with /api
to our backend server. Change the vite.config.js
with this configuration:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 4242,
proxy: {
// string shorthand
// with options
"/api": {
target: "http://localhost:5252",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});
We’ve also changed the Vite development server port from 3000
to 4242
, so we’ll need to restart the server and load http://localhost:4242 in the browser.
Running both the Vite server and the node server
In development, we can run both the Vite server and the node server by installing the concurrently
package, we’ll install this as a dev dependency:
npm install -D concurrently
Next we’ll update our package.json
to start both the Vite and Node servers with some custom scripts. Update the scripts block in package.json
with the following:
"scripts": {
"start": "npm run development",
"development": "NODE_ENV=development concurrently --kill-others \"npm run client\" \"npm run server\"",
"client": "vite",
"server": "node server.js",
Note that we’ve renamed the script that starts Vite from dev
to client
. The new scripts are server
, to start the node server, development
, which runs both the client
and server
scripts concurrently, and then finally start
, which runs the development script. If we run npm run start
we should see both the Vite server and node server boot up.
vite-project matthewling$ npm run start
> vite-project@0.0.0 start
> npm run development
> vite-project@0.0.0 development
> NODE_ENV=development concurrently --kill-others "npm run client" "npm run server"
^[[B[1]
[1] > vite-project@0.0.0 server
[1] > node server.js
[1]
[0]
[0] > vite-project@0.0.0 client
[0] > vite
[0]
[0]
[0] vite v2.9.12 dev server running at:
[0]
[0] > Local: http://localhost:4242/
[0] > Network: use `--host` to expose
[0]
[0] ready in 304ms.
[0]
[1] (Use `node --trace-warnings ...` to show where the warning was created)
[1] {"level":30,"time":1655285637895,"pid":93847,"hostname":"matthewling","msg":"Server listening at http://127.0.0.1:5252"}
[1] {"level":30,"time":1655285637898,"pid":93847,"hostname":"matthewling","msg":"Server listening at http://[::1]:5252"}
[1] Server listening ...
We can run two simple tests now to make sure that our proxying is working properly. This cURL call should return the publishable key directly from the backend:
curl http://localhost:5252/publishable-key
And this call should return the publishable key, proxied through the Vite development server to the backend:
curl http://localhost:4242/api/publishable-key
Initializing Stripe.js
Now that we have a backend running, we can jump back to our Checkout component. After the imports, we’ll write an async function called initStripe
that will initialize Stripe.js by using the loadStripe
function that we imported earlier. This async function will call our backend to retrieve the publishable key and then will load Stripe.js returning a promise that will be passed to the Elements provider later.
import { useEffect, useState } from "react";
import axios from "axios";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import CheckoutForm from './CheckoutForm.jsx';
const initStripe = async () => {
const res = await axios.get("/api/publishable-key");
const publishableKey = await res.data.publishable_key;
return loadStripe(publishableKey);
};
We’ll add the call to initStripe
at the top of the declaration to create the Checkout component:
const Checkout = () => {
const stripePromise = initStripe();
Don’t forget that our Vite server is now running on 4242
, not 3000
so we’ll need to navigate to http://localhost:4242 instead.
Creating a Payment Intent and saving the client secret
Next we’ll use useEffect
to create a Payment Intent. Here we’ll create an async function to create the Payment Intent and then use setState
to set the clientSecretSettings
object that we created earlier. Don’t forget to include an empty dependency array to instruct useEffect
to run only once when the component is loaded. Note that when we used useState
earlier, that the default value for loading
was true
, we’ll set that to false when setting the clientSecretSettings
. We’ll use that loading state in the JSX HTML next to signify two states when rendering the component, a loading state and a loaded state.
useEffect(() => {
async function createPaymentIntent() {
const response = await axios.post("/api/create-payment-intent", {});
setClientSecretSettings({
clientSecret: response.data.client_secret,
loading: false,
});
}
createPaymentIntent();
}, []);
Creating a CheckoutForm component
We’ll create one more component which will be a form to render the Payment Element. Then we’ll wrap that form in the Elements provider later. In the src
folder, create a CheckoutForm.jsx
file:
import { PaymentElement } from "@stripe/react-stripe-js";
const CheckoutForm = () => {
return (
<form>
<PaymentElement />
<button>Submit</button>
</form>
);
};
export default CheckoutForm;
Using the Elements provider
Back in our Checkout
component, let’s import that CheckoutForm
component:
import { useEffect, useState } from "react";
import axios from "axios";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import CheckoutForm from './CheckoutForm.jsx';
Next we’ll modify the JSX in the Checkout
component use our loading
state, but more importantly, we need to wrap the CheckoutForm
component with the Elements
provider passing the stripePromise
which was loaded earlier:
return (
<div>
{clientSecretSettings.loading ? (
<h1>Loading ...</h1>
) : (
<Elements
stripe={stripePromise}
options={{
clientSecret: clientSecretSettings.clientSecret,
appearance: { theme: "stripe" },
}}
>
<CheckoutForm />
</Elements>
)}
</div>
);
Now we should see the Payment Element rendering in the browser.
Confirming the payment
To recap, we’ve completed the following steps:
- Created a
Checkout
component - Set Up a backend that can return a publishable key and create a Payment Intent
- Used the
Checkout
component to load Stripe.js and to create a Payment Intent and save a client secret - Created a
CheckoutForm
component that can render a Payment Element - Used the
Elements
provider to wrap the CheckoutForm to provide the stripe object in nested components
Finally, we’ll confirm the payment when the checkout form is submitted using Stripe.js in the CheckoutForm
. In CheckoutForm.jsx
:
import React, { useState } from 'react';
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';
const CheckoutForm = () => {
const stripe = useStripe();
const elements = useElements();
const [errorMessage, setErrorMessage] = useState(null);
const handleSubmit = async (event) => {
// We don't want to let default form submission happen here,
// which would refresh the page.
event.preventDefault();
if (!stripe || !elements) {
// Stripe.js has not yet loaded.
// Make sure to disable form submission until Stripe.js has loaded.
return;
}
const {error} = await stripe.confirmPayment({
//`Elements` instance that was used to create the Payment Element
elements,
confirmParams: {
return_url: 'http://localhost:4242/success.html',
},
});
if (error) {
// This point will only be reached if there is an immediate error when
// confirming the payment. Show error to your customer (for example, payment
// details incomplete)
setErrorMessage(error.message);
} else {
// Your customer will be redirected to your `return_url`. For some payment
// methods like iDEAL, your customer will be redirected to an intermediate
// site first to authorize the payment, then redirected to the `return_url`.
}
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button disabled={!stripe}>Submit</button>
{/* Show error message to your customers */}
{errorMessage && <div>{errorMessage}</div>}
</form>
)
};
export default CheckoutForm;
Let’s walk through this code.
- We’ll import
useStripe
anduseElements
from react stripe - We’ll then use the
useStripe
anduseElements
hooks to access thestripe
andelements
objects - We’ll setup error message state using
useState
- When the form is submitted, we’ll prevent the default action which is the form submission
- We use a guard conditional statement to simply return if either
stripe
orelements
is not loaded - Finally we’ll call
confirmPayment
passing the elements instance and the required confirmParams which is a return url. We’ll return to an emptysuccess.html
page. - In the root of the project, let's create an empty
success.html
file to redirect to - If an error occurs, this will be returned immediately which we’ll handle by using the
setErrorMessage
state. - The form tag is also augmented to handle the form submission and disabling the button should
stripe
not be loaded.
Testing
You can use any of the standard Stripe test cards to test the Payment Element. On successful payment we’ll be redirected to the success.html
page. Note that the query parameters passed to this page are the Payment Intent ID, client secret and redirect status. These can be used to retrieve the Payment Intent from the API to report on the status of the payment. For payment methods like Bancontact, that must redirect to an intermediary bank, we’ll be redirected to a Stripe hosted test page — where we can authorize or fail the payment — and then back to success.html
page.
Conclusion
Being able to support the Payment Element using modern frontend technologies is essential to maintaining and increasing payments conversion. With React Stripe and the Payment Element, you can simply offer many different methods of payment using the same integration.
You can see the finished repo for this post on the main branch here. We’d love to hear any feedback on what you’ve learned and built along the way! Keep your eyes peeled for new posts on this series where we’ll modify this code to add new payment methods and features.
About the author
Matthew Ling (@mattling_dev) is a Developer Advocate at Stripe. Matt loves to tinker with new technology, adores Ruby and coffee and also moonlighted as a pro music photographer. His photo site is at matthewling.com and developer site is at mattling.dev.
Stay connected
In addition, you can stay up to date with Stripe in a few ways:
📣 Follow us on Twitter
💬 Join the official Discord server
📺 Subscribe to our Youtube channel
📧 Sign up for the Dev Digest
Top comments (0)