DEV Community

Cover image for Building a React/Express Stripe Donation Form
Nathaniel Arfin
Nathaniel Arfin

Posted on

Building a React/Express Stripe Donation Form

Building a React/Express Stripe Checkout Form

Payments are scary. Really, really, really scary. Thankfully, for developers who are looking to get started with payments, and are incredibly afraid (again, I don’t blame you, payments are scary), Stripe has their low-code Checkouts tool. It’s an awesome way to get started.

But what if you’re ready for the next challenge? What if you want to take the leap into payments, and you want to dive right in! Well, you’ve come to the right place. We’re going to learn payments!

In this walkthrough, I will be working through the set up of a React app for the front-end, an Express server for the backend, we’ll containerize our server with express, and we can serve up the React assets directly wherever we need!

Getting started.

Now I don’t have anything in particular to sell. I haven’t been actively seeking donations, and I’m not about to start, but I feel like it’s more natural to make a donation for than a sales one, so that’s the direction I’m going.

Head over to Stripe.com, and either create or sign in to your account. Head over to the Developers section and turn on Test Mode. Navigate to the API keys tab, and copy your Publishable and Secret keys.

Now open up your favourite IDE, create a directory, and throw in an npm init -y. We’re going to start out by creating our express server. Let’s get our dependancies installed. npm i stripe express cors dotenv nodemon will get us everything we need! Now give it a touch index.js.

Now, navigate into your package.json and update the start command. I also prefer ES modules, so I’m going to update my package.json like so:

{
  "name": "donations",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "dev": "nodemon index.js",
    "start": "node index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "cors": "^2.8.5",
        "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "nodemon": "^3.0.1",
    "stripe": "^12.16.0",
  }
}
Enter fullscreen mode Exit fullscreen mode

We can now use npm run dev to start the server using nodemon, which enables hot refreshing on changes.

Next, create a .env file, and paste in the Stripe publishable key and secret key, like this:

# .env

STRIPE_PUBLIC_KEY=pk_test_xxxxxx
STRIPE_SECRET_KEY=sk_test_xxxxxx
Enter fullscreen mode Exit fullscreen mode

Setting up our Express server

For the most part, this is a very basic express server, not at all dissimlar from the one I outlined in my FERN series. We’ll start by importing our dependancies, and setting up the basics:

// index.js

import Stripe from 'stripe';
import express, {json} from 'express';
import * as dotenv from 'dotenv';
import cors from 'cors';

dotenv.config();

const port = process.env.PORT || 3000;
const app = express();
app.use(cors());
app.use(json());

app.use((error, req, res, next) => {
    res.status(500).json({ error: error.message });
});

app.listen(port, () => {
    console.log(\`Server is running on port ${port}\`);
});
Enter fullscreen mode Exit fullscreen mode

Here, we’ve create a very straightforward express server. Right now you can fire it up, and it will start on port 3000, or whatever you’ve specified in your .env file. Right now, it will not serve up any response, but we’ll get to that.

Set up Stripe

Let’s get started with our Stripe instance! Since we have the secret key in our .env file, and our dependancies installed, all we have to do is create the instance.

// index.js

import Stripe from 'stripe';
import express, {json} from 'express';
import * as dotenv from 'dotenv';
import cors from 'cors';

dotenv.config();

const port = process.env.PORT || 3003;
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.use(cors());
app.use(json());

app.use((error, req, res, next) => {
    res.status(500).json({ error: error.message });
});

app.listen(port, () => {
    console.log(\`Server is running on port ${port}\`);
});
Enter fullscreen mode Exit fullscreen mode

Congrats! You have Stripe set up on your express server! Now let’s create something that will interact with it.

Set up the Front End

My preferred React stack is Vite (it’s french for fast), MUI (with icons), React Query, and React Router. I’m also a heathen who refuses to learn Typescript. In this case, because it’s an incredibly simple plug-in, we won’t be using a router.

To get started, we can throw in npm create vite@latest client --template-react, answer any questions it may have, and then cd client. Now we’ve created a boilerplate Vite/React app. Let’s strip out all of the defaults:

/* App.css */

#root{
  margin: 0;
}
Enter fullscreen mode Exit fullscreen mode
// App.jsx

import './App.css'

function App() {

  return (
    <>
    </>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Great! You can also dispose of anything in index.css and replace it with any global font imports you would like. I’m using Roboto:

/* index.css */

@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');

@font-face {
  font-family: 'Roboto';
  src: url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap');;
  font-weight: normal;
  font-style: normal;
}
Enter fullscreen mode Exit fullscreen mode

Next, install the dependancies we need:

    npm install @mui/material @emotion/react @emotion/styled @mui/icons-material react-query @stripe/react-stripe-js @stripe/stripe-js
Enter fullscreen mode Exit fullscreen mode

We’re going to be sending our requests through to our localhost backend, so we need to set up our proxy in ‘vite.config.js’, make sure to set up the correct port:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  server:{
    proxy:{
      '/api': {
        target: "http://localhost:PORT",
        changeOrigin: true,
      }
    }
  },
  plugins: [react()],
})
Enter fullscreen mode Exit fullscreen mode

Now, we’re going to make use of the Stripe Payment Element for our form. This element leans heavily on the Payment Intents API. When you create a Payment Intent, you’re generating an authenticated session for each client session. And while there are many parameters available when you create a payment intent, we’re only going to focus on those which are required - an amount, a currency, and some **kind of payment* -* I’ll cricle back to this.

Now that we have the basics set up, we can get started in actually building our form! Throw in an npm run dev and your browser should open to an empty screen. Which is perfect.

We’re going to start by creating our contexts - our MUI theme, and a Query Provider for our Queries.

We’ll start with our theme:

// themes/theme.js

const theme = createTheme({
    palette: {
      primary: { main: '#bc232a', },
      secondary: { main: '#c9dde9' },
    },
    typography:{
        fontFamily: [
            'Roboto',
            '-apple-system',
            'sans-serif',
            '"Apple Color Emoji"',
            '"Segoe UI Emoji"',
            '"Segoe UI Symbol"',
          ].join(','),
    }
  });

  export default theme;
Enter fullscreen mode Exit fullscreen mode

Here, we’re defining our basic colour scheme, our typography, and can centrally control the appearance of our MUI components.

And then our Query Provider:

import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';

const queryClient = new QueryClient();

export const QueryProvider = ({ children }) => (
  <QueryClientProvider client={queryClient}>
    {children}
  </QueryClientProvider>
);
Enter fullscreen mode Exit fullscreen mode

This creates a central cache for any queries we make, making it easy to retrieve and update information. Now we’ll implement these!

// App.jsx

import './App.css'
import { ThemeProvider } from '@mui/material/styles';
import theme from './themes/theme';
import { QueryProvider } from './QueryProvider';

function App() {

  return (
    <ThemeProvider theme={theme}>
      <QueryProvider>

      </QueryProvider>
    </ThemeProvider>
  );
}

export default App
Enter fullscreen mode Exit fullscreen mode

And there you have it! We have a theme, we have our Query Provider, now we just need something to put in it!

Creating a Form

I mentioned before, the 3 things we need in order to create a Payment Intent are an amount, a currency and a payment method. Since we’re doing a donation form, I’m going to focus on the amount. Let’s build a basic form!

Create DonationForm.jsx, and we’ll get started with a very basic component:

// DonationForm.jsx

import { Card, CardContent, Typography } from "@mui/material";

export default function DonationForm() {
    return (
    <Card>
        <CardContent>
            <Typography>
                Buy me a Coffee?
            </Typography>
        </CardContent>
    </Card>)
}
Enter fullscreen mode Exit fullscreen mode

Import that in to your App.jsx:

// App.jsx

import './App.css'
import { ThemeProvider } from '@mui/material/styles';
import theme from './themes/theme';
import { QueryProvider } from './QueryProvider';
import DonationForm from './DonationForm';

function App() {

  return (
    <ThemeProvider theme={theme}>
      <QueryProvider>
        <DonationForm/>
      </QueryProvider>
    </ThemeProvider>
  );
}

export default App
Enter fullscreen mode Exit fullscreen mode

And you’ll see a beautiful screen:

Basic Screen

Awesome. Now let’s collect an amount.

// DonationForm.jsx

import { Card, CardContent, Typography, Grid, InputAdornment, OutlinedInput } from "@mui/material";

export default function DonationForm() {
    return (
        <Card>
            <CardContent>
                <Grid container spacing={2}>
                    <Grid item xs={12}>
                        <Typography>
                            Buy me a Coffee?
                        </Typography>
                    </Grid>
                    <Grid item xs={6}>
                        <OutlinedInput
                            type="text"
                            placeholder="10"
                            startAdornment={<InputAdornment position="start">$</InputAdornment>}
                        />
                    </Grid>
                </Grid>
            </CardContent>
        </Card>)
}
Enter fullscreen mode Exit fullscreen mode

Here, I’ve put in an input field, I’ve set up in Input Adronment to prefix it with a “$”, and I’ve set a placeholder amount of $10. Looking good!

Now, we actually need to handle that amount, and do something about it when it’s changed, and we’ll throw a submit button in ther for good measure.

// DonationForm.jsx

import { Card, CardContent, Typography, Grid, InputAdornment, OutlinedInput, Button } from "@mui/material";
import { useState } from "react";

export default function DonationForm() {
    const [amount, setAmount] = useState(10);

    const handleChange = (e) => {
        setAmount(e.target.value);
    }

    return (
        <Card>
            <CardContent>
                <Grid container spacing={2} justifyContent={"center"}>
                    <Grid item xs={12}>
                        <Typography>
                            Buy me a Coffee?
                        </Typography>
                    </Grid>
                    <Grid item xs={6}>
                        <Grid container spacing={2}>
                            <Grid item xs={12}>
                                <OutlinedInput
                                    type="text"
                                    value={amount}
                                    onChange={handleChange}
                                    startAdornment={<InputAdornment position="start">$</InputAdornment>}
                                    fullWidth
                                />
                            </Grid>
                            <Grid item xs={12}>
                                <Button fullWidth variant="contained" type="submit">
                                    Donate
                                </Button>
                            </Grid>
                        </Grid>
                    </Grid>
                </Grid>
            </CardContent>
        </Card>)
}
Enter fullscreen mode Exit fullscreen mode

Adding an input

Awesome! That looks great. Now we need to handle this form. We’re going to use the useMutation hook from Tanstack Query to make our request. First, make ‘functions/createPaymentIntent.js’, which will house the fetch function:

// createPaymentIntent.js

export const createPaymentIntent = async (amount) => {
    const response = await fetch('/api/create-payment-intent', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        amount: amount,
      }),
    });

    if (!response.ok) {
      throw new Error('Failed to fetch payment intent client secret');
    }
    const data = await response.json();
    return data.paymentIntent;
  };
Enter fullscreen mode Exit fullscreen mode

Then we’ll define a custom mutation hook in ‘hooks/useCreatePaymentIntent’:

//useCreatePaymentIntent.js 

import { useMutation } from 'react-query';
import {createPaymentIntent} from '../api/createPaymentIntent';

export const useCreatePaymentIntent = () => {
  const mutation = useMutation(createPaymentIntent);
  return mutation;
};
Enter fullscreen mode Exit fullscreen mode

And then we’ll define how to use it!

// DonationForm.jsx

import { Card, CardContent, Typography, Grid, InputAdornment, OutlinedInput, Button, CircularProgress } from "@mui/material";
import { useState } from "react";
import { useCreatePaymentIntent } from "../hooks/useCreatePaymentIntent";

export default function DonationForm() {
    const [amount, setAmount] = useState(10);
    const { mutate, isLoading, data, error } = useCreatePaymentIntent();
    const handleChange = (e) => {
        setAmount(e.target.value);
    }
    const handleSubmit = () => (mutate(amount));

    return (
        <Card>
            <CardContent>
                <Grid container spacing={2} justifyContent={"center"}>
                    <Grid item xs={12}>
                        <Typography>
                            Buy me a Coffee?
                        </Typography>
                    </Grid>
                    <Grid item xs={6}>
                        <Grid container spacing={2}>
                            <Grid item xs={12}>
                                <OutlinedInput
                                    type="text"
                                    value={amount}
                                    onChange={handleChange}
                                    startAdornment={<InputAdornment position="start">$</InputAdornment>}
                                    fullWidth
                                />
                            </Grid>
                            <Grid item xs={12}>
                                <Button fullWidth variant="contained" type="submit" onClick={handleSubmit} disabled={isLoading}>
                                    {isLoading ? <CircularProgress/> : 'Donate'}
                                </Button>
                                {error && <Typography variant="alert">Something went wrong</Typography>}
                            </Grid>
                        </Grid>
                    </Grid>
                </Grid>
            </CardContent>
        </Card>)
}
Enter fullscreen mode Exit fullscreen mode

Here, we’ve set up the mutation implementation. We’re calling it on submit and passing the amount into the function, we’re disabling the button and showing circular progress when it’s loading, and we’re displaying an error message when something goes wrong.

Now it’s time to hop back to our Express server.

Setting up our routes.

Let’s get started creating our first route. As we defined in our mutation, we’re targeting /api/create-payment-intent. Here, we’re going to handle the donation amount, and use it to generate a Payment Intent with Stripe.

Let’s define our route! First, create a ‘routes/stripe.js’ file:

// stripe.js

import express from 'express';

export default function createStripeRoutes(stripe) {
    const router = express.Router();

    router.post('/api/create-payment-intent', async (req, res) => {

    })
    return router;
}
Enter fullscreen mode Exit fullscreen mode

Here, we’re defining our route, and we’re going to pass in the stripe client. We’ll define it’s use in index.js:

// index.js

import Stripe from 'stripe';
import express, {json} from 'express';
import * as dotenv from 'dotenv';
import cors from 'cors';
import createStripeRoutes from './routes/stripe.js';

dotenv.config();

const port = process.env.PORT || 3003;
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const stripeRoutes = createStripeRoutes(stripe);

app.use(cors());
app.use(json());
app.use(stripeRoutes);

app.use((error, req, res, next) => {
    res.status(500).json({ error: error.message });
});

app.listen(port, () => {
    console.log(`Server is running on port ${port}`);
});
Enter fullscreen mode Exit fullscreen mode

Here, we’re importing our new createStripeRoutes factory, passing in our dependancy, and then defining it’s use. Now, we have to define our payment intent logic!

Update our route as follows:

// stripe.js

import express from 'express';

export default function createStripeRoutes(stripe) {
    const router = express.Router();

    router.post('/api/create-payment-intent', async (req, res) => {
        const { amount } = req.body;
        try {
            const paymentIntent = await stripe.paymentIntents.create({
                amount: amount * 100,
                currency: 'cad',
                automatic_payment_methods: { enabled: true },
            });
            res.send({
                paymentIntent
            });
        } catch (error) {
            console.log(error);
            res.status(500).json({ error: error.message });
        }
    })
    return router;
}
Enter fullscreen mode Exit fullscreen mode

I’ve mentioned now a couple of times, to generate a payment intent, we need an amount - we’re passing this in through our mutation, above you’ll notice it’s multipled by 100, that’s because it comes in in cents (hundreths of a dollar); a currency - I’m Canadian, I’m going to use CAD, and a kind of payment.

What I mean by that is you can either specify payment_method_types which includes forms of payment like “card”, “affirm”, “cashapp”, or basically any other form of payment method. The alternative is to use automatic_payment_methods:{enable:true} . This method detects the payment method at capture, so that you don’t have to specify. You can also define what payment methods you accept on the front end.

Let’s pop back there.

Tidying up & Integrating with Stripe

Now, if you press that “Donate” button, and check your Network Tab, you should see that “create-payment-intent” function, and a nice big paymentIntent response. Now it’s time to do something with it. Before we get too much further, let’s tidy up our ‘DonationForm.jsx’.

Once we have our payment intent, we no longer need any of the current card content. Let’s create ‘DonationInput.jsx’:

import { CardContent, Grid, Typography, OutlinedInput, InputAdornment, Button, CircularProgress } from "@mui/material";

const DonationInput = ({ amount, handleChange, handleSubmit, isLoading, error }) => (
    <CardContent>
        <Grid container spacing={2} justifyContent={"center"}>
            <Grid item xs={12}>
                <Typography>
                    Buy me a Coffee?
                </Typography>
            </Grid>
            <Grid item xs={6}>
                <Grid container spacing={2}>
                    <Grid item xs={12}>
                        <OutlinedInput
                            type="text"
                            value={amount}
                            onChange={handleChange}
                            startAdornment={<InputAdornment position="start">$</InputAdornment>}
                            fullWidth
                        />
                    </Grid>
                    <Grid item xs={12}>
                        <Button fullWidth variant="contained" type="submit" onClick={handleSubmit} disabled={isLoading}>
                            {isLoading ? <CircularProgress /> : 'Donate'}
                        </Button>
                        {error && <Typography variant="alert">Something went wrong</Typography>}
                    </Grid>
                </Grid>
            </Grid>
        </Grid>
    </CardContent>);
export default DonationInput;
Enter fullscreen mode Exit fullscreen mode

Head back to our ‘DonationForm.jsx’ and import our new component and pass our variables!

// DonationForm.jsx

import { Card } from "@mui/material";
import { useState } from "react";
import { useCreatePaymentIntent } from "../hooks/useCreatePaymentIntent";
import DonationInput from "./DonationInput";

export default function DonationForm() {
    const [amount, setAmount] = useState(10);
    const { mutate, isLoading, data, error } = useCreatePaymentIntent();
    const handleChange = (e) => {
        setAmount(e.target.value);
    }
    const handleSubmit = () => (mutate(amount));

    return (
        <Card>
            <DonationInput amount={amount} handleChange={handleChange} handleSubmit={handleSubmit} isLoading={isLoading} data={data} error={error}/>
        </Card>)
};
Enter fullscreen mode Exit fullscreen mode

Now, let’s update this to store the paymentIntent in a state. Because we’re pulling the data in through a hook, we’ll use another to listen for the update:

// DonationForm.jsx

import { Card } from "@mui/material";
import { useEffect, useState } from "react";
import { useCreatePaymentIntent } from "../hooks/useCreatePaymentIntent";
import DonationInput from "./DonationInput";

export default function DonationForm() {
    const [amount, setAmount] = useState(10);
    const [paymentIntent, setPaymentIntent] = useState(null);
    const { mutate, isLoading, data, error } = useCreatePaymentIntent();
    const handleChange = (e) => {
        setAmount(e.target.value);
    }
    const handleSubmit = () => (mutate(amount));

    useEffect(() => {
        if (data) setPaymentIntent(data);
    }, [data]);

    return (
        <Card>
            <DonationInput amount={amount} handleChange={handleChange} handleSubmit={handleSubmit} isLoading={isLoading} data={data} error={error}/>
        </Card>)
}
Enter fullscreen mode Exit fullscreen mode

Here, we’re listening for updates to data. If data exists, it will update the paymentIntent accordingly.

Now, let’s do something with that paymentIntent. Remember, I said if we have a paymentIntent, we no longer need the DonationInput. But we don’t want it do just have it instantly disappear. We’re going to use MUI’s Fade transition to help us unmount the component and have it transition away.

// DonationForm.jsx

import { Card, Fade, Container } from "@mui/material";
import { useEffect, useState } from "react";
import { useCreatePaymentIntent } from "../hooks/useCreatePaymentIntent";
import DonationInput from "./DonationInput";

export default function DonationForm() {
    const [amount, setAmount] = useState(10);
    const [paymentIntent, setPaymentIntent] = useState(null);
    const { mutate, isLoading, data, error } = useCreatePaymentIntent();
    const handleChange = (e) => {
        setAmount(e.target.value);
    }
    const handleSubmit = () => (mutate(amount));

    useEffect(() => {
        if (data) setPaymentIntent(data);
    }, [data]);

    return (
        <Card>
            <Fade in={!paymentIntent} unmountOnExit>
                <Container>
                    <DonationInput amount={amount} handleChange={handleChange} handleSubmit={handleSubmit} isLoading={isLoading} data={data} error={error} />
                </Container>
            </Fade>
        </Card>)
}
Enter fullscreen mode Exit fullscreen mode

Great! Now when you click Donate, the input fades away, making room for what’s to come next.

Building the Stripe Form

Let’s create a new component called “StripeForm.jsx”. Before we get any further, you’ll need to create a “client/.env” file. Vite has a very cool feature which enables imported environment variables to be statically replaced at build, so you can set your .env as follows:

VITE_STRIPE_PUBLIC_KEY=pk_test_xxxxx
Enter fullscreen mode Exit fullscreen mode

Now, we’re going to use that Public key to create a session for our checkout:

//StripeForm.jsx

import { CardContent } from "@mui/material";
import { Elements, PaymentElement, } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";

const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLIC_KEY);

const StripeForm = ({ paymentIntent }) => {
    return (
        <CardContent>
            <Elements stripe={stripePromise} options={{ clientSecret: paymentIntent.client_secret }}>
                <PaymentElement options={{ mode: 'payment' }} />
            </Elements>
        </CardContent>
    )
}
export default StripeForm
Enter fullscreen mode Exit fullscreen mode

Here, we’re taking the Elements component from Stripe, passing into it the client session we’ve created with the Public key, we’re also passing in the unique secret from the paymentIntent. Next, we’re creating our PaymentElement, and specifying that it’s a payment.

With that set up, your app should be looking something like this:

Image description
Amazing! We now have a spot for people to enter their details and make their donations.

Now let’s style it a bit, and add some Buttons. I’m going to put the amount on the button:

// StripeForm.jsx 

import { Button, CardActionArea, CardActions, CardContent, Typography } from "@mui/material";
import { Elements, PaymentElement, } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";

const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLIC_KEY);

const StripeForm = ({ paymentIntent }) => {
    return (
        <CardContent>
            <Typography variant="h6" pb={3} color='primary'>Thanks for your support!</Typography>
            <Elements stripe={stripePromise} options={{ clientSecret: paymentIntent.client_secret }}>
                <PaymentElement options={{ mode: 'payment' }} />
            </Elements>
                <CardActions sx={{mt:3, display:'flex', justifyContent:'space-between'}}>
                    <Button variant="outlined">Cancel</Button>
                    <Button variant="contained">Donate ${paymentIntent.amount / 100}</Button>
                </CardActions>
        </CardContent>
    )
}
export default StripeForm
Enter fullscreen mode Exit fullscreen mode

And now you’ll have something that looks like this:

Donate form

Let’s create some logic for those buttons! First, let’s handle the cancel logic.

Easy enough, we just need to clear the paymentIntent:

// DonationForm.jsx

//...Other Code

const handleClear = () => {
        setPaymentIntent(null);
 }

//...Other Code

<Fade in={paymentIntent} unmountOnExit>
    <Container>
      <StripeForm paymentIntent={paymentIntent} handleCancel={handleClear} />
  </Container>
</Fade>

//...More code
Enter fullscreen mode Exit fullscreen mode

Simple enough. Now, on to our payment logic! We’re going to use a custom mutation, just like we did with the payment intent. We’re going to pass in the required variables to confirm a payment, the elements, our stripe client, and the secret from our payment intent.

First, create a new file called ‘functions/capturePayment.js’

const capturePayment = async (elements, stripe, clientSecret) => {
    if (!stripe) {
        throw new Error("Stripe hasn't yet loaded.");
    }
    const { error: submitError } = await elements.submit();
    if (submitError) {
        throw submitError;
    }

    const { error } = await stripe.confirmPayment({
        elements,
        clientSecret,
        redirect: "if_required",
    });

    if (error) {
        throw error;
    }
    else return {status: "success"};
};
export default capturePayment;
Enter fullscreen mode Exit fullscreen mode

And then a new hook ‘hooks/useCapturePayment.js’ to use our function in a mutation:

import { useMutation } from 'react-query';
import capturePayment from '../functions/capturePayment';

export const useSubmitPayment = (elements, stripe, amount) => {
  const mutation = useMutation(() => capturePayment(elements, stripe, amount));
  return mutation;
};
Enter fullscreen mode Exit fullscreen mode

Now, we’ll add the logic into our Stripe form component! It’s going to end up looking very similar to our Donation form.

import { Button, CardActions, CardContent, CircularProgress, Typography } from "@mui/material";
import { PaymentElement, } from "@stripe/react-stripe-js";
import { useSubmitPayment } from "../hooks/useCapturePayment";
import { useElements, useStripe } from "@stripe/react-stripe-js";
import { useState, useEffect } from "react";

const StripeForm = ({ paymentIntent, handleClear, }) => {
    const [confirmData, updateConfirmData] = useState(null);

    const stripe = useStripe();
    const elements = useElements();

    const { mutate, isLoading, data, error } = useSubmitPayment(elements, stripe, paymentIntent.client_secret);

    const handleSubmit = async (e) => {
        elements.submit();
        e.preventDefault();
        mutate();
    };

    useEffect(() => {
        if (data) updateConfirmData(data);
    }, [data]);

    return (
    <CardContent>
        <Typography variant="h6" pb={3} color='primary'>Thanks for your support!</Typography>
        <PaymentElement/>
        <CardActions sx={{ mt: 3, display: 'flex', justifyContent: 'space-between' }}>
            <Button variant="outlined" onClick={handleClear}>Cancel</Button>
            <Button variant="contained" onClick={handleSubmit} disabled={isLoading}>{isLoading ? <CircularProgress/> :`Donate ${paymentIntent.amount / 100}`}</Button>
        </CardActions>
    </CardContent>
)
}
export default StripeForm
Enter fullscreen mode Exit fullscreen mode

Here, we’ve imported the useStripe and useElements hooks, which we then pass into our custom hook. We’re disabling the button while it’s loading, and we’re using useEffect to listen for data.

We now have a working test form! It can accept payments as is. You can test it with the Test Cards that Stripe has available.

Now, we’re going to want to set up some confirmation logic. Let’s lift up the data to our ‘DonationForm’.

First, we’ll add “confirmPayment” to our StripeForm, and update our useEffect:

const StripeForm = ({ paymentIntent, handleClear, confirmPayment}) => {
//...

useEffect(() => {
        if (data) confirmPayment(data);
    }, [data]);

//...
}
Enter fullscreen mode Exit fullscreen mode

Then, we’ll navigate up to the parent “DonationForm”, and add our handler and state:

export default function DonationForm() {
    const [amount, setAmount] = useState(10);
    const [paymentIntent, setPaymentIntent] = useState(null);
    const [confirmedPayment, setConfirmedPayment] = useState(null);
    const { mutate, isLoading, data, error } = useCreatePaymentIntent();

    const handleSubmit = () => (mutate(amount));

    const handleClear = () => {
        setPaymentIntent(null);
    }
    const handleChange = (e) => {
        setAmount(e.target.value);
    }

    const confirmPayment = (payment) => {
        setConfirmedPayment(payment);
                handleClear();
    }

    useEffect(() => {
        if (data) setPaymentIntent(data);
    }, [data]);

//...
}
Enter fullscreen mode Exit fullscreen mode

We’re also going to have to adjust our StripeForm to ensure our hook doesn’t throw an error if the payment intent is cleared. Since we need the client_secret and amount, that’s all we’ll pass in!

const StripeForm = ({ client_secret, amount, handleClear, handleConfirmPayment}) => {

    const stripe = useStripe();
    const elements = useElements();

    const { mutate, isLoading, data, error } = useSubmitPayment(elements, stripe, client_secret);

    const handleSubmit = async (e) => {
        elements.submit();
        e.preventDefault();
        mutate();
    };

    useEffect(() => {
        if (data) handleConfirmPayment(data);
    }, [data]);
        return (
        <CardContent>
        <Typography variant="h6" pb={3} color='primary'>Thanks for your support!</Typography>
        <PaymentElement/>
        <CardActions sx={{ mt: 3, display: 'flex', justifyContent: 'space-between' }}>
            <Button variant="outlined" onClick={handleClear}>Cancel</Button>
            <Button variant="contained" onClick={handleSubmit} disabled={isLoading}>{isLoading ? <CircularProgress/> :`Donate $${amount / 100}`}</Button>
        </CardActions>
        </CardContent>
        )
}
export default StripeForm
Enter fullscreen mode Exit fullscreen mode

And then update it in the DonationForm:

// DonationForm.jsx

<Fade in={!!paymentIntent} unmountOnExit>
    <Container>
        <Elements stripe={stripePromise} options={{clientSecret:paymentIntent?.client_secret}}>
            <StripeForm client_secret={paymentIntent?.client_secret} amount={paymentIntent?.amount} handleClear={handleClear} handleConfirmPayment={handleConfirmPayment}/>
        </Elements>
    </Container>
</Fade>
Enter fullscreen mode Exit fullscreen mode

Now, let’s build out a thank you message, display it, then clear our confirmed payment after 5 seconds:

// DonationForm.jsx

import { Card, Fade, Container, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useCreatePaymentIntent } from "../hooks/useCreatePaymentIntent";
import { Elements, } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import DonationInput from "./DonationInput";
import StripeForm from "./StripeForm";

const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLIC_KEY);

export default function DonationForm() {
    const [amount, setAmount] = useState(10);
    const [paymentIntent, setPaymentIntent] = useState(null);
    const [confirmedPayment, setConfirmedPayment] = useState(null);
    const { mutate, isLoading, data, error } = useCreatePaymentIntent();

    const handleSubmit = () => (mutate(amount));

    const handleClear = () => {
        setPaymentIntent(null);
    }
    const handleChange = (e) => {
        setAmount(e.target.value);
    }

    const handleConfirmPayment = async (payment) => {
        setConfirmedPayment(payment);
        handleClear();
        await setTimeout(() => {
            setConfirmedPayment(null);
        }, 5000);
    }

    useEffect(() => {
        if (data) setPaymentIntent(data);
    }, [data]);

    return (
        <Card>
            <Fade in={!paymentIntent && !confirmedPayment} unmountOnExit>
                <Container>
                    <DonationInput amount={amount} handleChange={handleChange} handleSubmit={handleSubmit} isLoading={isLoading} data={data} error={error} />
                </Container>
            </Fade>
            <Fade in={!!paymentIntent && !confirmedPayment} unmountOnExit>
                <Container>
                    <Elements stripe={stripePromise} options={{ clientSecret: paymentIntent?.client_secret }}>
                        <StripeForm client_secret={paymentIntent?.client_secret} amount={paymentIntent?.amount} handleClear={handleClear} handleConfirmPayment={handleConfirmPayment} />
                    </Elements>
                </Container>
            </Fade>
            <Fade in={!!confirmedPayment} unmountOnExit>
                <Typography p={4} variant="h6" textAlign={'center'}>Your generosity goes a long way!</Typography>
            </Fade>
        </Card>
    )
}
Enter fullscreen mode Exit fullscreen mode

We’ve set it up so that the thank you message displays for 5 seconds, then it resets and goes back to the original page!

Image description

Wow that is a tiny GIF! But look at our payment form go! Now, let’s get on to our next step.

Hosting the thing.

We have some choices here. We could treat this as it’s own web app, and let it do it’s thing. If we wanted to put it on a website, we could throw it in an iframe and display it that way. But that’s pretty limiting, as Wallets like Apple Pay and Google Pay aren’t available in iframes, and theyre not reactive, meaning when the form changes size, the frame size wont change accordingly.

Instead, we’re going to serve it as a microfrontend. Since this form has nothing to do with any of the rest of my website, I don’t need to share any state with it ever, I can just slap it into a div and let it do it’s thing!

To do this, we’re going to need to first host our server. We’re going to build a container with Docker, and host it on Google Cloud. If you need an intro on setting up Google Cloud for hosting a container, check out the finale for my FERN series.

We’re going to follow the same basic steps to make our container. In the root of the app, make a new ‘Dockerfile’.

FROM node:alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ARG PORT=3000
EXPOSE $PORT
ENV PORT=$PORT
CMD npm start
Enter fullscreen mode Exit fullscreen mode

Here, we define the working directory as /app, and copy the package.jsonand package-lock.json files from the root into the /app directory. Next, we run npm install to ensure the necessary dependancies are installed, we copy our files in, accept a port and start up the app.

Next, build the container with docker build -t gcr.io/yourprojectname/yourappname . ( or docker buildx build --platform linux/amd64 -t [gcr.io/yourprojectname/yourappname] if you’re on an M1 Mac), and then push it to Google with docker push gcr.io/yourprojectname/yourappname.

Head to cloud run, and create a service for it:

https://res.cloudinary.com/practicaldev/image/fetch/s--bZNgdxO2--/c_limit,f_auto,fl_progressive,q_auto,w_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rth7xiirjpezw90n7txo.png

Select your container, and provide your service with a name. Next, select a region, and allow all traffic:

https://res.cloudinary.com/practicaldev/image/fetch/s--0IT0AxIW--/c_limit,f_auto,fl_progressive,q_auto,w_800/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9vb23yzs6c9rwlltlrqb.png

Below, you’re able to set your PORT, Arguments, runtime variables, and configure secrets. Here, you can input your Stripe Secret Key. Since we’re only testing, I’m ok passing it in as an environment variable.

If you plan to deploy your app to production, I highly recommend using the Secrets Manager API to protect your sensitive keys.

Once we’re happy with your settings, deploy your container, and you have yourself an express server!

Grab your new server’s URL, and lets head back into our React app!

Prepping the Front End

First, we’re going to want to head into our ‘main.jsx’ file, and update the target root. Because we’re putting this into an existing React website, this thing will need it’s own target.

//main.jsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'

ReactDOM.createRoot(document.getElementById('stripeRoot')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)
Enter fullscreen mode Exit fullscreen mode

Next, we need to update our ‘createPaymentIntent.js’ file with our updated URL.

// createPaymentIntent.js

const createPaymentIntent = async (amount) => {
    const response = await fetch('https://yourserviceurl.run.app/api/create-payment-intent', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        amount: amount,
      }),
    });

    if (!response.ok) {
      throw new Error('Failed to fetch payment intent client secret');
    }
    const data = await response.json();
    return data.paymentIntent;
  };
  export {createPaymentIntent};
Enter fullscreen mode Exit fullscreen mode

Once that’s done, it’s ready to be built! Throw npm run build into terminal from our client directory, which will build our the react app into a ‘dist’ folder.

Now, we’ll head over to Google Cloud Storage, and create a new bucket. Ensure that you have Public Access preventions turned OFF.

Image description
Next, we’ll upload the contents of our ‘dist/assests’ folder that we created with our build.

Edit the access on both of these files to enable a public user:

Image description

Go back in to your bucket after saving, and you should see both files as “Public to internet”. Copy the Public URL to both your .js and .css files.

Next, head to the website where you want to install your donation form! I set up a page just for it on my website:

//Donate.jsx
import { Typography, Grid, Box } from "@mui/material";
const Donate = () => {
    return (
        <Grid container p={4}>
            <Grid item xs={12}>
                <Typography variant="h1">Donate!</Typography>
            </Grid>
            <Grid item xs={6} p={4} alignItems={'center'} display={'flex'} flexDirection={'column'} position={'relative'}>
                <Box height={'400px'}></Box>
                <Box width={'300px'} height={'100%'}>
                    <Typography textAlign={'center'} variant="h3" position={'relative'} zIndex={'1'}>
                        Hey, thanks for supporting me. I really appreciate it!
                    </Typography>
                </Box>
                <Box
                    sx={{
                        backgroundImage: 'url(/NJA-50.png)',
                        opacity: '.7',
                        height: '300px',
                        width: '300px',
                        backgroundSize: 'contain',
                        position: 'absolute',
                        zIndex: 0
                    }}
                />
            </Grid>
            <Grid item xs={6} p={4}>
            </Grid>
        </Grid>
    )
};
export default Donate;
Enter fullscreen mode Exit fullscreen mode

Here, we put in our “stripeRoot” div, which we specified as our target for our Donation form. Next, we have to attach our our script to the document. In React apps, you can’t run a "<script>" in the app code, so we’ll use an effect.


// Donate.jsx
import { useEffect } from "react";
import { Typography, Grid, Box } from "@mui/material";
const Donate = () => {
    useEffect(() => {
        const script = document.createElement('script');
        script.src = 'https://storage.googleapis.com/donations-app/index-607d50b6.js';
        script.async = true;
        document.body.appendChild(script);

        return () => {
            document.body.removeChild(script);
        }
    }, []);
    return (
        <Grid container p={4}>
            <Grid item xs={12}>
                <Typography variant="h1">Donate!</Typography>
            </Grid>
            <Grid item xs={6} p={4} alignItems={'center'} display={'flex'} flexDirection={'column'} position={'relative'}>
                <Box height={'400px'}></Box>
                <Box width={'300px'} height={'100%'}>
                    <Typography textAlign={'center'} variant="h3" position={'relative'} zIndex={'1'}>
                        Hey, thanks for supporting me. I really appreciate it!
                    </Typography>
                </Box>
                <Box
                    sx={{
                        backgroundImage: 'url(/NJA-50.png)',
                        opacity: '.7',
                        height: '300px',
                        width: '300px',
                        backgroundSize: 'contain',
                        position: 'absolute',
                        zIndex: 0
                    }}
                />
            </Grid>
            <Grid item xs={6} p={4}>
                <div id="stripeRoot"></div>
            </Grid>
        </Grid>
    )
};
export default Donate;
Enter fullscreen mode Exit fullscreen mode

And just like that, oue donation form is live on our website!

Image description

When you’re ready to build for production, you can crate a new revision of the server with your Live Secret Key, a new React app build with your Live Client key, re-upload the new build files, and you’re good to go.

Want to see a live version? https://nathanielarfin.com/donate

Top comments (0)