How to: implement payments in your app
Álvaro
Posted on July 25, 2023
Hi there! It's been a while since I didn't post, so I hope I didn't lost my magic.
Today we are gonna talk about payments and how we can set up single payments in our webapp. Also, we will cover subscriptions (or recurring payments) in upcoming posts.
Personally, I think that payments are one of the less explored universe but one of the greatests. I've learned a lot working on a fintech startup.
We are going to use Stripe, Remix, Typescript and Tailwind. I'm choosing these technologies because they are one of the most (if not the most) popular in the web development.
First of all, let's create our project and get our app running.
To do this, follow me.
You can check the final project here: https://dev-to-stripe.vercel.app
Payment data:
Card: 4242 4242 4242 4242
Date: 12/34
CVV: 123repository: https://github.com/AlvaroJSnish/dev-to-stripe
Create the project
Open your terminal and create a new remix app:
npx create-remix@latest
It's very straightforward, choose you app name, then just a basic app. For the server, you can choose wathever you want, but I'm sticking with Vercel. Finally, Typescript and we are good to go.
Selecting Vercel is not necessary anymore to deploy on Vercel, maybe you see a warning or maybe not. Anyways, is not important or blocking right now.
Before we get to code, let's deploy our app.
Deploy
First, we initialize git inside our repository:
git init
Now we need to create a repository on Github (or the tool you are using for).
Add your origin to the project:
git remote add origin https://github.com/yourusername/yourrepository.git
Change the branch to main, add the code, commit and push:
git branch -M main
git add .
git commit -m "First commit"
git push -u origin main
Now, head to Vercel and log in or create an account (you can link your github account). Then you can create a new project and import the repository that we just created. It takes less than a minute. Continue to the dashboard to see your project; it will live in an url like: yourreponame.vercel.app
Now everytime that we push to main it will be deployed on our URL, so we can test our changes live.
Add Tailwind
Finally in the funny part! Before we get to code, let's add a dependency to make our app pretty: tailwind.
In remix.config.js, let's add tailwind support:
module.exports = {
...
tailwind: true,
...
};
Now install Tailwind and initialize its config:
npm install -D tailwindcss
npx tailwindcss init --ts
Let's tell Tailwind which files to generate classes from. In tailwind.config.js:
content: ["./app/**/*.{js,jsx,ts,tsx}"],
In the app directory, create a tailwind.css file and add these lines:
@tailwind base;
@tailwind components;
@tailwind utilities;
Finally in app/root.ts, let's add tailwind:
import styles from "./tailwind.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
...(cssBundleHref
? [
{ rel: "stylesheet", href: cssBundleHref },
]
: []),
];
Now if we go to app/routes/_index.tsx and we change the content to:
export default function Index() {
return <h1 className="text-xl text-green-400">Hello World!</h1>;
}
We can see that tailwind it's working.
Set up Stripe
The hardest part of everything is signing up for Stripe. Not joking. Go to https://stripe.com and create an account. There is a lot of information to fill, but don't worry, not everything is necessary and I believe that you can skip most of it. Just think that they ask for that much because it's intended to go live a make real payments.
Once you have your account, make sure you are in test mode: top right, check it.
If you go to developers > API Keys, you can see your publish and private keys. Copy them.
Now, go to your project and create .env at the root, then paste them:
STRIPE_PUBLIC_KEY=...
STRIPE_SECRET_KEY=...
Now let's install dotenv and Stripe:
npm i dotenv stripe
In app/entry.server.tsx import dotenv at the top:
import "dotenv/config";
Now, inside app, create a file named stripe.server.ts:
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("Missing Stripe secret key");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
Here we are just making sure that we have the key and create a new stripe client.
We are done by now.
It's important to declare our variables in Vercel too:
In your project, go to settings > environment variables. There you can create the same variables that we just create on the .env file.
Create and sell products
Before we get into it, it's necessary to clarify something: in Stripe, like in the real world, you can sell one-time products (a shirt, car, computer, etc) or you can sell recurrent products (netflix subscription, gym pass, etc).
Depending on which flow you choose to checkout your customers, you may be required to create the products on the Stripe dashboard first. Recurrent products always have to exists in the dashboard, but one-time products not.
Go to the Stripe dashboard > products > create a new product. Create a product (one time, please), add the information, images, all that you want.
Once you have it, save it.
Go to app/routes/_index.tsx and add these lines to the code:
import { stripe } from "~/stripe.server";
export const loader: LoaderFunction = async () => {
const products = await stripe.products.list();
return { products: products.data };
};
Here, we are using the Stripe client we created before, and with the loader function we are server-side fetching our products.
You can choose the classnames and styles that you want, but I keep it very simple:
export default function Index() {
const data = useLoaderData<{
products: Array<Stripe.Product & { default_price: Stripe.Price }>;
}>();
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<div className="flex flex-col space-y-10">
{data.products.map((product) => (
<Product key={product.id} product={product} />
))}
</div>
</div>
);
}
function Product({
product,
}: {
product: Stripe.Product & { default_price: Stripe.Price };
}) {
const amount = new Intl.NumberFormat("en-US", {
style: "currency",
currency: product.default_price.currency,
}).format((product.default_price.unit_amount || 0) / 100);
return (
<div className="flex flex-row max-w-[400px]">
<div className="w-200 mr-4">
<img src={product.images[0]} alt={product.name} />
</div>
<div className="flex flex-col justify-center">
<h2 className="text-xl font-bold mb-2">{product.name}</h2>
<p className="text-sm font-light mb-3">{product.description}</p>
<p>{amount}</p>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mt-4 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-opacity-50">
Buy
</button>
</div>
</div>
);
}
Now we are going to create a server action to actually buy our product.
Modify the the Product component to include a hidden input field and pass a new property, to check if we are loading the checkout:
function Product({
product,
loading,
}: {
product: Stripe.Product & { default_price: Stripe.Price };
loading: boolean;
}) {
const amount = new Intl.NumberFormat("en-US", {
style: "currency",
currency: product.default_price.currency,
}).format((product.default_price.unit_amount || 0) / 100);
return (
<div className="flex flex-row max-w-[400px]">
<input type="hidden" name="product_id" value={product.id} />
<div className="w-200 mr-4">
<img src={product.images[0]} alt={product.name} />
</div>
<div className="flex flex-col justify-center">
<h2 className="text-xl font-bold mb-2">{product.name}</h2>
<p className="text-sm font-light mb-3">{product.description}</p>
<p>{amount}</p>
<button
disabled={loading}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mt-4 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-opacity-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Loading..." : "Buy"}
</button>
</div>
</div>
);
}
Now modify the main component:
export default function Index() {
const data = useLoaderData<{
products: Array<Stripe.Product & { default_price: Stripe.Price }>;
}>();
const navigation = useNavigation();
const isBuying = navigation.state !== "idle";
return (
<Form
method="post"
className="flex flex-col items-center justify-center min-h-screen py-2"
>
<div className="flex flex-col space-y-10">
{data.products.map((product) => (
<Product key={product.id} product={product} loading={isBuying} />
))}
</div>
</Form>
);
}
All that's left here is the action function. Without that, nothing works.
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const product_id = formData.get("product_id")?.toString();
if (!product_id) {
return new Response("Product not found", { status: 404 });
}
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: [
{
quantity: 1,
price: product_id,
},
],
mode: "payment",
success_url: `${process.env.APP_URL}/success`,
cancel_url: `${process.env.APP_URL}/cancel`,
});
if (!session.url) {
return new Response("Session not found", { status: 404 });
}
return redirect(session.url, {
headers: {
"cache-control": "no-cache",
},
});
};
If you noticed, we created a new env variable named APP_URL. Add that to your .env:
APP_URL=http://localhost:XXXX
And don't forget to add that to Vercel too! It should look like this:
https://yourappname.vercel.app
Now we have everything!
Oh... wait! We need our success and cancel routes!
Inside app/routes create cancel.tsx and success.tsx:
import { Link } from "@remix-run/react";
export default function Cancel() {
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<div className="flex flex-col space-y-10">
<h1 className="text-3xl font-bold">Payment Cancelled</h1>
<p className="text-xl">Your payment has been cancelled.</p>
<Link to="/">Go back home</Link>
</div>
</div>
);
}
import { Link } from "@remix-run/react";
export default function Success() {
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<div className="flex flex-col space-y-10">
<h1 className="text-3xl font-bold">Payment Successful!</h1>
<p className="text-xl">Your payment has been successful.</p>
<Link to="/">Go back home</Link>
</div>
</div>
);
}
Test the app
Click on your buy button. You should be redirected to the checkout page, great! Now fill the inputs with your info, and use:
Card: 4242 4242 4242 4242
Date: 12/34
CVV: 123
If you click the buy button and everything goes fine, you should redirected to the success page, but if you cancel of something goes wrong, you will be redirected to the cancel page.
If you go to: https://dashboard.stripe.com/test/payments you can check all the payments you received.
Maybe it's time to go live? That's up to you!
I hope that you learned something new today, and if that's the case, I'm more than happy!
Feel free to reach for any inquiries.
Thank you all!
Álvaro.
Posted on July 25, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.