DEV Community

Cover image for Accept a payment with Vite, React Stripe, and the Payment Element
mattling_dev for Stripe

Posted on • Edited on

Accept a payment with Vite, React Stripe, and the Payment Element

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:

  1. Start a brand new Vite project
  2. Create a Checkout component to initialize a payment flow
  3. Create a simple Node backend to return a publishable key and create a Payment Intent
  4. Run both the Vite server and the Node server concurrently
  5. Create a Checkout Form component to render the Payment Element
  6. 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Now we’ll install dependencies and run the dev server.

npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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.

React screen

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We’ll also install axios to help with making calls to a backend server:

npm install --save axios
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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 boolean loading 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;
Enter fullscreen mode Exit fullscreen mode

Checkout heading

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;
Enter fullscreen mode Exit fullscreen mode

Setting up a backend

To setup a simple backend to interact with the Stripe API we’ll perform the following:

  1. Install the require dependencies, in this case dotenv, fastify and stripe
  2. Setup our keys in a .env file (used by dotenv)
  3. Create a server.js for two backend routes
  4. Configure Vite to proxy calls to the backend
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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/, ""),
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And this call should return the publishable key, proxied through the Vite development server to the backend:

curl http://localhost:4242/api/publishable-key
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

We’ll add the call to initStripe at the top of the declaration to create the Checkout component:

const Checkout = () => {
  const stripePromise = initStripe();
Enter fullscreen mode Exit fullscreen mode

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();
  }, []);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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>
  );
Enter fullscreen mode Exit fullscreen mode

Now we should see the Payment Element rendering in the browser.

Payment Element

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;
Enter fullscreen mode Exit fullscreen mode

Let’s walk through this code.

  • We’ll import useStripe and useElements from react stripe
  • We’ll then use the useStripe and useElements hooks to access the stripe and elements 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 or elements 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 empty success.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

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)