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...
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
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>
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...')
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
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)
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
}
})
}
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: passingpayment_method_types
is legacy, preferautomatic_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()
}
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>
)
}
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>
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>
)
}
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>
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'
}
})
}
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'
}
})
}
🎶 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>
)
}
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)
}
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>
</>
)
}
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