How to: implement payments in your app

alvarojsnish

Álvaro

Posted on July 25, 2023

How to: implement payments in your app

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: 123

repository: https://github.com/AlvaroJSnish/dev-to-stripe

Create the project

Open your terminal and create a new remix app:

npx create-remix@latest
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Now install Tailwind and initialize its config:

npm install -D tailwindcss
npx tailwindcss init --ts
Enter fullscreen mode Exit fullscreen mode

Let's tell Tailwind which files to generate classes from. In tailwind.config.js:

content: ["./app/**/*.{js,jsx,ts,tsx}"],
Enter fullscreen mode Exit fullscreen mode

In the app directory, create a tailwind.css file and add these lines:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

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

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

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

Now let's install dotenv and Stripe:

npm i dotenv stripe
Enter fullscreen mode Exit fullscreen mode

In app/entry.server.tsx import dotenv at the top:

import "dotenv/config";
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

If you noticed, we created a new env variable named APP_URL. Add that to your .env:

APP_URL=http://localhost:XXXX
Enter fullscreen mode Exit fullscreen mode

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

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.

💖 💪 🙅 🚩
alvarojsnish
Á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.

Related