DEV Community

Cover image for How to accept payments in a Remix application with Stripe
CJ Avilla for Stripe

Posted on • Updated on

How to accept payments in a Remix application with Stripe

Remix is a full stack web framework that lets you focus on the user interface and work back through web standards to deliver a fast, slick, and resilient user experience. - Remix.run.

We can quickly add Stripe into a Remix application to start accepting one time or recurring payments.

There are several options for accepting payments with Stripe that range in implementation complexity. This article covers the most involved integration path with Stripe Elements, but I wanted to share a few shorter-paths which you should choose if you’re able to let Stripe do the heavy lifting.

NB: No matter which option you chose, you’ll want a webhook handler to automate fulfillment. Here’s code for a Remix action to setup a webhook handler for Stripe webhooks.

No-code

Stripe Payment Links is a no-code option where you create a link directly from the Stripe Dashboard that you can share with customers via email or on social. Payment Links are great if you don’t need to customize the payment flow per-customer and they can be used to accept both one-time and recurring payments. Pick Payment Links if you don’t need to keep track of or customize each payment flow per-customer.

Low-code

Stripe Checkout is an option where you use one API call server-side to customize the payment flow per customer and redirect to Stripe. You can also customize the look and feel to pretty closely match your colors, fonts, and things like border-radius. Pick Checkout if you don’t need full control over the payment form inputs.

The most code

Stripe Elements is the option we’ll discuss today. Elements are pre-built form components that you can embed directly into your web application (there’s mobile versions too!). There’s an official react-stripe-js library that exposes the new PaymentElement. The PaymentElement supports several payment methods and is the best default. You should only implement individual elements (like one for Card and one for SEPA) if you need some very specific functionality, otherwise, consider individual elements legacy. Pick PaymentElement if you need to full control over the payment form.

Summary: If the choice still isn’t clear, take a look at Charles’ “Making sense of Stripe Checkout, Payment Links, and the Payment Element.” My recommendation is to start with PaymentLinks, if that’s not feature rich enough graduate to Checkout, if that’s not enough control, step up into PaymentElement and read on!


Pop open that terminal and let’s install dependencies.

  • stripe is stripe-node for server side API calls to Stripe.
  • @stripe/stripe-js helps load Stripe.js, a client side library for working with payment details.
  • @stripe/react-stripe-js contains all the React-specific components and hooks that help with integrating Stripe. This is also a client-side library and depends on @stripe/stripe-js.

npm add stripe @stripe/stripe-js @stripe/react-stripe-js

Set your secret API key as an environment variable in .env in the root of your project. You can find your API keys in the Stripe dashboard:

STRIPE_SECRET_KEY=sk_test...
Enter fullscreen mode Exit fullscreen mode

Let’s get into the code!

We’ll create a new route called /pay with two nested UI components: one for the payment form and one for the order confirmation page where we show a success message after payment.

Start by adding these files and directories to routes:

  app/
    - routes/
      - pay/
          - index.tsx
          - success.tsx
      pay.tsx
Enter fullscreen mode Exit fullscreen mode

Then let’s tackle the outer structure of the payment flow with pay.tsx.

In order to render any Stripe Elements in our React application, we need to use the Elements provider. The Elements provider wraps any subcomponents that render Stripe Elements. It expects a stripe prop and since we’re using the PaymentElement, we also need to pass an options prop.

<Elements stripe={stripePromise} options={{clientSecret: paymentIntent.client_secret}}>
  Some subcomponents that will render Stripe Elements... more on this later.
</Elements>
Enter fullscreen mode Exit fullscreen mode

Okay, but you’ll notice that code uses two things we don’t know about yet: stripePromise and paymentIntent. Read on, friend!

The stripe prop wants either reference to a fully initialized instance of the Stripe.js client, or a promise that will eventually resolve to one. Luckily, @stripe/stripe-js has a helper for this called loadStripe where we pass in our publishable API key and get back a promise.

const stripePromise = loadStripe('pk_test...')
Enter fullscreen mode Exit fullscreen mode

One down, one to go, but first, some context.

In order to render a PaymentElement with all the bells and whistles like multiple payment methods, localization, and the correct currency support, it needs a PaymentIntent or a SetupIntent. For now, we only need to know that we need one of those, but if you want to read more, take a look at "Level up your integration with SetupIntents” or “The PaymentIntents Lifecycle” by Paul.

To keep the code nice and organized in Remix, let’s create a new file in the app/ directory called payments.ts that will serve as our container for all API interactions to Stripe.

    app/
      - routes/
          - pay/
              - index.tsx
              - success.tsx
          pay.tsx
      - payments.ts
Enter fullscreen mode Exit fullscreen mode

We need to make an API call to Stripe to create a PaymentIntent so that we can use the resulting PaymentIntent to initialize our Elements provider and ultimately render the PaymentElement.

Let’s get stripe-node initialized and ready to make calls with our secret key from the .env. Remix differs a bit from Next.js in how they handle environment variables that are used on the client side. The Remix docs have an example showing how to pass environment variables to the client. Incidentally, the example is for Stripe publishable (public) keys!

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
Enter fullscreen mode Exit fullscreen mode

Next, we’ll export a function that we can use from loaders in our views.

export async function createPaymentIntent() {
  return await stripe.paymentIntents.create({
    amount: 2000,
    currency: 'usd',
    automatic_payment_methods: {
      enabled: true
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

stripe.paymentIntents.create makes an API call to Stripe’s /v1/payment_intents endpoint with some args. Let’s talk about each of those:

  • amount this is the amount in the smallest currency denomination. In this case cents, so we’re hoping to eventually charge $20.
  • currency the currency the customer will pay with.
  • automatic_payment_methods Stripe supports more than just credit card payments. You can accept cards, but also bank accounts, buy-now-pay-later, and vouchers too! This argument will enable the PaymentIntent to derive the list of supported payment method types from the settings in your Stripe dashboard. This means you can enable and disable different types of payment directly in the dashboard instead of needing to change any code. This is 100% a best practice! Note: passing payment_method_types is legacy, prefer automatic_payment_methods.

Let’s head back to our Payment form and use the resulting PaymentIntent’s client_secret in our options.

By exporting a loader, we can create a payment intent that’s available when we render our component later:

export const loader = async () => {
  return await createPaymentIntent()
}
Enter fullscreen mode Exit fullscreen mode

Then we can pull in the useLoaderData hook from Remix and use that to access the PaymentIntent in our component. Now our incomplete Pay component looks like this:

import {useLoaderData} from 'remix'
import {Elements} from '@stripe/react-stripe-js'
import {loadStripe} from '@stripe/stripe-js'

const stripePromise = loadStripe('pk_test...');

export default function Pay() {
  const paymentIntent = useLoaderData();

  return (
    <Elements
      stripe={stripePromise}
      options={{clientSecret: paymentIntent.client_secret}}>
      Seriously, when are we going to talk about what goes here??
    </Elements>
  )
}
Enter fullscreen mode Exit fullscreen mode

It works! Let’s talk about the children that go inside of Elements, now.

Remix is built on React router, so gives us the Outlet component. We’ll use that to render our nested UI components, including the payment form:

import {Outlet} from 'remix'

<Elements
  stripe={stripePromise}
  options={{clientSecret: paymentIntent.client_secret}}>
  <Outlet />
</Elements>
Enter fullscreen mode Exit fullscreen mode

Outlet will be replaced by the nested UI components inside of the /pay directory. Let’s go add our payment form to /app/routes/pay/index.tsx

Remix provides a Form component that works very similar to the HTML form element. We’ll pull that in and add a little button:

import {Form} from 'remix'

export default function Index() {
  return (
    <Form>
      <button>Pay</button>
    </Form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now we can drop in our PaymentElement component and you should see something fancy render on your screen when browsing to http://localhost:3000/pay!

import {PaymentElement} from '@stripe/react-stripe-js'

//... 

<Form>
  <PaymentElement />
  <button>Pay</button>
</Form>
Enter fullscreen mode Exit fullscreen mode

PaymentElement rendered on a page

We still need to wire up the form so that it passes those payment details to Stripe and confirms the payment. One of the nice things about Remix is that you can usually have forms submit directly to the server, then handle the form data in an action. In this case, we’re tokenizing (fancy way of saying we’re sending the raw card numbers directly to Stripe and getting back a token), the payment details. That tokenization step happens client side so that the raw card numbers never hit your server and help you avoid some extra PCI compliance burden.

Technically, you may still want to submit the form data to your server after confirming payment. Remix offers a useSubmit hook for that.

To confirm client side, we use a Stripe.js helper called confirmPayment in a submit handler for the form, the same way we would in any other React application.

The submit handler might look something like this:

const handleSubmit = async (e) => {
  e.preventDefault();

  await stripe.confirmPayment({
    elements,
    confirmParams: {
      return_url: 'http://localhost:3000/pay/success'
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Hold on a min, again new unknown variables! Where does elements come from? Is that the same stripe object we created on the server?

Sheesh, impatient developers are hard to please! 😄

Remember that stripe prop we passed into the Elements provider earlier? Well, react-stripe-js has a handy hook for getting access to that in a child component like our payment form: useStripe. If you’ve used Stripe.js before without React, you may be familiar with the elements object that is used for creating the older Card element. The Elements provider also creates an elements object for us that we can get through useElements. Two nice hooks are the answer to our mystery, here’s the code:

import {useStripe, useElements} from '@stripe/react-stripe-js'

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

const handleSubmit = async (e) => {
  e.preventDefault();

  await stripe.confirmPayment({
    elements,
    confirmParams: {
      return_url: 'http://localhost:3000/pay/success'
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

🎶 all together now!

import {Form} from 'remix'
import {PaymentElement, useElements, useStripe} from '@stripe/react-stripe-js'

export default function Index() {
  const elements = useElements();
  const stripe = useStripe();
  const submit = useSubmit();

  const handleSubmit = async (e) => {
    e.preventDefault();

    await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: 'http://localhost:3000/pay/success'
      }
    })
  }

  return (
      <Form onSubmit={handleSubmit}>
        <PaymentElement />

        <button>Pay</button>
      </Form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Notice the return_url on the confirmParams we passed into confirmPayment that’s the URL to which the customer will be redirected after paying. The URL will include query string params for the ID and client secret of the PaymentIntent.

Recall that nested UI component we added at the beginning, /app/routes/pay/success.tsx? That’s the view that will be rendered when customers are redirected and where we can show the order confirmation or success page.

We’ll add a new helper to ~/payments.ts for fetching the PaymentIntent server side:

export async function retrievePaymentIntent(id) {
  return await stripe.paymentIntents.retrieve(id)
}
Enter fullscreen mode Exit fullscreen mode

Then use that to build our Success page:

import {useLoaderData} from 'remix'
import {retrievePaymentIntent} from '~/payments'

export const loader = async ({request}) => {
  const url = new URL(request.url);
  const id = url.searchParams.get("payment_intent")
  return await retrievePaymentIntent(id)
}

export default function Success() {
  const paymentIntent = useLoaderData();
  return (
    <>
      <h3>Thank you for your payment!</h3>
      <pre>
        {JSON.stringify(paymentIntent, null, 2)}
      </pre>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

If you must use a custom form, then use the guide here to implement the PaymentElement with automatic_payment_methods enabled.

Please let us know if you have any feedback by tweeting @stripedev. Join us on Discord if you have any questions about how to get up and running: discord.gg/stripe.

Find the full project here for reference: https://github.com/cjavilla-stripe/remix-stripe-sample

Top comments (0)