DEV Community

Cover image for 🦄 Building a pricing page with NextJS 🤯 🤯
Nevo David for Gitroom

Posted on

🦄 Building a pricing page with NextJS 🤯 🤯

TL;DR

In this article, we will build a page with multiple pricing tiers.
Visitors can press the "Purchase" button and go to a checkout page.

Once completed, we will send the customers to a success page and save them into our database.

This use case can be helpful in:

  • Purchase a course
  • Purchase a subscription
  • Purchase a physical item
  • Donate money
  • Buy you a coffee 😉

And so many more.

pricing


Can you help me out? ❤️

I love making open-source projects and sharing them with everybody.

If you could help me out and star the project I would be super, super grateful!

(this is also the source code of this tutorial)
https://github.com/github-20k/growchief

stargif

cat


Let's set it up 🔥

Let's start by creating a new NextJS project:

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

Just click enter multiple times to create the project.
I am not a big fan of Next.JS's new App router - so I will use the old pages folder, but feel free to do it your way.

Let's go ahead and add the pricing packages.
Let's make a new folder called components and add our pricing component.

mkdir components
cd components
touch pricing.component.tsx
Enter fullscreen mode Exit fullscreen mode

And add the following content:

export const PackagesComponent = () => {
  return (
    <div className="mt-28">
      <h1 className="text-center text-6xl max-sm:text-5xl font-bold">
        Packages
      </h1>
      <div className="flex sm:space-x-4 max-sm:space-y-4 max-sm:flex-col">
        <div className="flex-1 text-xl mt-14 rounded-xl border border-[#4E67E5]/25 bg-[#080C23] p-10 w-full">
          <div className="text-[#4d66e5]">Package one</div>
          <div className="text-6xl my-5 font-light">$600</div>
          <div>
            Short description
          </div>
          <button
            className="my-5 w-full text-black p-5 max-sm:p-2 rounded-3xl bg-[#4E67E5] text-xl max-sm:text-lg hover:bg-[#8a9dfc] transition-all"
          >
            Purchase
          </button>
          <ul>
            <li>First feature</li>
            <li>Second feature</li>
          </ul>
        </div>
        <div
          className="flex-1 text-xl mt-14 rounded-xl border border-[#9966FF]/25 bg-[#120d1d] p-10 w-full"
        >
          <div className="text-[#9967FF]">Package 2</div>
          <div className="text-6xl my-5 font-light">$1500</div>
          <div>
            Short Description
          </div>
          <button
            className="my-5 w-full text-black p-5 max-sm:p-2 rounded-3xl bg-[#9966FF] text-xl max-sm:text-lg hover:bg-[#BB99FF] transition-all"
          >
            Purchase
          </button>
          <ul>
            <li>First Feature</li>
            <li>Second Feature</li>
            <li>Thired Feature</li>
          </ul>
        </div>
        <div
          className="flex-1 text-xl mt-14 rounded-xl border border-[#F7E16F]/25 bg-[#19170d] p-10 w-full"
        >
          <div className="text-[#F7E16F]">Package 3</div>
          <div className="text-6xl my-5 font-light">$1800</div>
          <div>
            Short Description
          </div>
          <button
            className="my-5 w-full text-black p-5 max-sm:p-2 rounded-3xl bg-[#F7E16F] text-xl max-sm:text-lg hover:bg-[#fdf2bb] transition-all"
          >
            Purchase
          </button>
          <ul>
            <li>First Feature</li>
            <li>Second Feature</li>
            <li>Thired Feature</li>
            <li>Fourth Feature</li>
            <li>Fifth Feature</li>
          </ul>
        </div>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

This is a very simple component with Tailwind (CSS) to show three types of packages ($600, $1500, and $1800). Once clicked on any of the packages, we will move the visitor to a purchase page where they can purchase the package.

Go to the root dir and create a new index page (if it doesn't exist)

cd pages
touch index.tsx
Enter fullscreen mode Exit fullscreen mode

Add the following code to the file:

import React from 'react';
import {PackagesComponent} from '../components/pricing.component';

const Index = () => {
   return (
   <>
     <PackagesComponent />
   </>
   )
}
Enter fullscreen mode Exit fullscreen mode

Pricing


Setting up your payment provider 🤑 💰

Most payment providers work in the same way.

  1. Send an API call to the payment provider with the amount you want to charge and the success page to send the user after the payment.

  2. You get a URL back from the API call with a link to the checkout page and redirect the user (user leaving your website).

  3. Once the purchase is finished, it will redirect the user to the success page.

  4. The payment provider will send an API call to a specific route you choose to let you know the purchase is completed (asynchronically)

I use Stripe - it is primarily accessible everywhere, but feel free to use your payment provider.

Head over to Stripe, click on the developer's tab, move to "API Keys," and copy the public and secret keys from the developer's section.

Stripe

Go to the root of your project and create a new file called .env and paste the two keys like that:

PAYMENT_PUBLIC_KEY=pk_test_....
PAYMENT_SECRET_KEY=sk_test_....
Enter fullscreen mode Exit fullscreen mode

Remember that we said Stripe would inform us later about a successful payment with an HTTP request?

Well... we need to

  1. Set the route to get the request from the payment
  2. Protect this route with a key

So while in the Stripe dashboard, head to "Webhooks" and create a new webhook.

Create

You must add an "Endpoint URL". Since we run the project locally, Stripe can only send us a request back if we create a local listener or expose our website to the web with ngrok.

I prefer the ngrok option because, for some reason, the local listener didn't always work for me (sometimes send events, sometimes not).

So while your Next.JS project runs, just run the following commands.

npm install -g ngrok
ngrok http 3000
Enter fullscreen mode Exit fullscreen mode

And you will see Ngrok serves your website in their domain. Just copy it.

Ngrok

And paste it into the Stripe webhook "Endpoint URL," also adding the path to complete the purchase /api/purchase

Purchase

After that, click "Select Events."
Choose "checkout.session.async_payment_succeeded" and "checkout.session.completed"

Image description

Click "Add event" and then "Add endpoint."

Click on the created event

Image description

Click on "Reveal" on "Signing key",

Reveal

copy it and open .env, and add

PAYMENT_SIGNING_SECRET=key
Enter fullscreen mode Exit fullscreen mode

Sending users to the checkout page 🚀

Let's start by installing Stripe and also some types (since I am using typescript)

npm install stripe --save
npm install -D stripe-event-types
Enter fullscreen mode Exit fullscreen mode

Let's create a new API route to create a checkout URL link for our users, depending on the price.

cd pages/api
touch prepare.tsx
Enter fullscreen mode Exit fullscreen mode

Here is the content of the file:

import type { NextApiRequest, NextApiResponse } from 'next'
const stripe = new Stripe(process.env.PAYMENT_SECRET_KEY!, {} as any);

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
) {
    if (req.method !== 'GET') {
        return res.status(405).json({error: "Method not allowed"});
    }

    if (!req.query.price || +req.query.price <= 0) {
        return res.status(400).json({error: "Please enter a valid price"});
    }

    const { url } = await stripe.checkout.sessions.create({
    payment_method_types: ["card"],
      line_items: [
        {
        price_data: {
          currency: "USD",
          product_data: {
            name: "GrowChief",
            description: `Charging you`,
          },
          unit_amount: 100 * +req.query.price,
        },
        quantity: 1,
      },
    ],
    mode: "payment",
    success_url: "http://localhost:3000/success?session_id={CHECKOUT_SESSION_ID}",
    cancel_url: "http://localhost:3000",
  });

  return req.json({url});
}
Enter fullscreen mode Exit fullscreen mode

Here is what's going on here:

  1. We set a new Stripe instance with the SECRET key from our .env file.
  2. We make sure the METHOD of the route is GET.
  3. We check that we get a query string of price higher than 0.
  4. We make a Stripe call to create a Stripe checkout URL. We purchased 1 item; you can probably see that the unit_amount is multiplied by 100. If we send 1, it would be $0.01; multiplied by a hundred will make it $1.
  5. We send the URL back to the client.

Let's open back our packages.component.tsx component and add the api call.

const purchase = useCallback(async (price: number) => {
   const {url} = await (await fetch(`http://localhost:3000/api/prepare?price=${price}`)).json();

   window.location.href = url;
}, []);
Enter fullscreen mode Exit fullscreen mode

And for the full code of the page

export const PackagesComponent = () => {
  const purchase = useCallback(async (price: number) => {
     const {url} = await (await fetch(`http://localhost:3000/api/prepare?price=${price}`)).json();

     window.location.href = url;
  }, []);

  return (
    <div className="mt-28">
      <h1 className="text-center text-6xl max-sm:text-5xl font-bold">
        Packages
      </h1>
      <div className="flex sm:space-x-4 max-sm:space-y-4 max-sm:flex-col">
        <div className="flex-1 text-xl mt-14 rounded-xl border border-[#4E67E5]/25 bg-[#080C23] p-10 w-full">
          <div className="text-[#4d66e5]">Package one</div>
          <div className="text-6xl my-5 font-light">$600</div>
          <div>
            Short description
          </div>
          <button onClick={() => purchase(600)}
            className="my-5 w-full text-black p-5 max-sm:p-2 rounded-3xl bg-[#4E67E5] text-xl max-sm:text-lg hover:bg-[#8a9dfc] transition-all"
          >
            Purchase
          </button>
          <ul>
            <li>First feature</li>
            <li>Second feature</li>
          </ul>
        </div>
        <div
          className="flex-1 text-xl mt-14 rounded-xl border border-[#9966FF]/25 bg-[#120d1d] p-10 w-full"
        >
          <div className="text-[#9967FF]">Package 2</div>
          <div className="text-6xl my-5 font-light">$1500</div>
          <div>
            Short Description
          </div>
          <button onClick={() => purchase(1200)}
            className="my-5 w-full text-black p-5 max-sm:p-2 rounded-3xl bg-[#9966FF] text-xl max-sm:text-lg hover:bg-[#BB99FF] transition-all"
          >
            Purchase
          </button>
          <ul>
            <li>First Feature</li>
            <li>Second Feature</li>
            <li>Thired Feature</li>
          </ul>
        </div>
        <div
          className="flex-1 text-xl mt-14 rounded-xl border border-[#F7E16F]/25 bg-[#19170d] p-10 w-full"
        >
          <div className="text-[#F7E16F]">Package 3</div>
          <div className="text-6xl my-5 font-light">$1800</div>
          <div>
            Short Description
          </div>
          <button onClick={() => purchase(1800)}
            className="my-5 w-full text-black p-5 max-sm:p-2 rounded-3xl bg-[#F7E16F] text-xl max-sm:text-lg hover:bg-[#fdf2bb] transition-all"
          >
            Purchase
          </button>
          <ul>
            <li>First Feature</li>
            <li>Second Feature</li>
            <li>Thired Feature</li>
            <li>Fourth Feature</li>
            <li>Fifth Feature</li>
          </ul>
        </div>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

We have added onClick on each button of the page with the right price to create the Checkout page.


Notion blew my mind 🤯

Notion is an excellent tool for knowledge & documentation.

I have been working for Novu for over a year and used Notion primarily for our team.

If you have ever played with Notion, you have probably noticed they have a slick editor - one of the best I have ever played with (at least for me).

I HAVE REALIZED YOU CAN USE NOTION CONTENT WITH AN API.

I opened a notion-free account and went out to check their pricing - I was sure they would not offer API for their free tier; I was very wrong, they do, and it's super fast.

Their most significant limitation is that they let you make a maximum of 3 requests per second - but that's not a big problem if you cache your website - aka getStaticProps.

Notion


Processing the purchase request and adding the leads to Notion 🙋🏻‍♂️

Remember we set a webhook for Stripe to send us a request once payment is completed?

Let's build this request, validate it, and add the customer to Notion.

Since the request is not a part of the user journey and sits on a different route, it's exposed to the public.

It means that we have to protect this route - Stripe offers a great way to validate it with Express, but since we are using NextJS, we need to modify it a bit, so let's start by installing Micro.

npm install micro@^10.0.1
Enter fullscreen mode Exit fullscreen mode

And open a new route for the purchase:

cd pages
touch purchase.tsx
Enter fullscreen mode Exit fullscreen mode

Open it up and add the following code:

/// <reference types="stripe-event-types" />

import Stripe from "stripe";
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";

const stripe = new Stripe(process.env.PAYMENT_SECRET_KEY!, {} as any);

export const config = { api: { bodyParser: false } };

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
    const signature = req.headers["stripe-signature"] as string;
    const reqBuffer = await buffer(req);
    const event = stripe.webhooks.constructEvent(
      reqBuffer,
      signature,
      process.env.PAYMENT_SIGNING_SECRET!
    ) as Stripe.DiscriminatedEvent;

    if (
      event.type !== "checkout.session.async_payment_succeeded" &&
      event.type !== "checkout.session.completed"
    ) {
      res.json({invalid: true});
      return;
    }

    if (event?.data?.object?.payment_status !== "paid") {
      res.json({invalid: true});
      return;
    }

    /// request is valid, let's add it to notion
}
Enter fullscreen mode Exit fullscreen mode

This is the code to validate the request; let's see what's going on here:

  1. We start by import typing (remember we installed stripe-event-types before).
  2. We set a new Stripe instance with our secret key.
  3. We tell the route not to parse it into JSON because Stripe sends us the request in a different format.
  4. We extract the stripe-signature from the header and use the constructEvent function to validate the request and tell us the event Stripe sent us.
  5. We check that we get the event checkout.session.async_payment_succeeded; if we get anything else, we ignore the request.
  6. If we succeeded, but the customer didn't pay, we also ignored the request.
  7. We have a place to write the logic of the purchase.

After this part, this is your chance to add your custom logic; it could be any of the:

  • Register the user to a newsletter
  • Register the user to the database
  • Activate a user subscription
  • Send the user a link with the course URL

And so many more.

For our case, we will add the user to Notion.


Setting up notion ✍🏻

Before playing with Notion, let's create a new Notion integration.

Head over to "My integrations."
https://www.notion.so/my-integrations

And click "New Integration"

Integration

After that just add any name and click Submit

Submit

Click on Show and copy the key

Show

Head over to your .env file and add the new key

NOTION_KEY=secret_...
Enter fullscreen mode Exit fullscreen mode

Let's head over to notion and create a new Database

Database

This database won't be exposed to the API unless we specify that, so click on the "..." and then "Add connections" and click the newly created integration.

Add integration

Once that is done, copy the ID of the database and add it to your .env file.

Image description

NOTION_CUSTOMERS_DB=your_id
Enter fullscreen mode Exit fullscreen mode

Now you can play with the field in the database any way you want.

I will stick with the "Name" field and add the customer's name from the Stripe purchase.

Not let's install notion client by running

npm install @notionhq/client --save
Enter fullscreen mode Exit fullscreen mode

Let's write the logic to add the customer's name to our database.

import { Client } from "@notionhq/client";

const notion = new Client({
  auth: process.env.NOTION_KEY,
});

await notion.pages.create({
   parent: {
      database_id: process.env.NOTION_CUSTOMERS_DB!,
   },
   properties: {
      Name: {
         title: [
            {
               text: {
                  content: event?.data?.object?.customer_details?.name,
               },
            },
         ],
      },
   },
});
Enter fullscreen mode Exit fullscreen mode

This code is pretty straightforward.
We set a new Notion instance with the Notion secret key and then create a new row in our database with the prospect's name.

And the full purchase code:

/// <reference types="stripe-event-types" />

import Stripe from "stripe";
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import { Client } from "@notionhq/client";

const notion = new Client({
  auth: process.env.NOTION_KEY,
});

const stripe = new Stripe(process.env.PAYMENT_SECRET_KEY!, {} as any);

export const config = { api: { bodyParser: false } };

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
    const signature = req.headers["stripe-signature"] as string;
    const reqBuffer = await buffer(req);
    const event = stripe.webhooks.constructEvent(
      reqBuffer,
      signature,
      process.env.PAYMENT_SIGNING_SECRET!
    ) as Stripe.DiscriminatedEvent;

    if (
      event.type !== "checkout.session.async_payment_succeeded" &&
      event.type !== "checkout.session.completed"
    ) {
      res.json({invalid: true});
      return;
    }

    if (event?.data?.object?.payment_status !== "paid") {
      res.json({invalid: true});
      return;
    }

    await notion.pages.create({
        parent: {
          database_id: process.env.NOTION_CUSTOMERS_DB!,
        },
        properties: {
          Name: {
            title: [
              {
                text: {
                  content: event?.data?.object?.customer_details?.name,
                },
              },
            ],
          },
        },
      });

    res.json({success: true});
  }
Enter fullscreen mode Exit fullscreen mode

You should have something like this:

Final results


You nailed it 🚀

That's all.
You can find the entire source code here:
https://github.com/github-20k/growchief

You will find more stuff there, such as

  • Displaying DEV.TO analytics
  • Collecting information from Notion and displaying it on the website (CMS style)
  • Entire purchase flow

Full Project


Can you help me out? ❤️

I hope this tutorial was helpful for you 🚀
Any star you can give me would help me tremendously
https://github.com/github-20k/growchief

stargif

Image description

Top comments (23)

Collapse
 
chantal profile image
Chantal

@nevodavid l want to start learning Next.js. So, is it good to start with this article as a practice question? Any advice from you will be greatly appreciated.

Collapse
 
nevodavid profile image
Nevo David

This article is a little bit more advanced, if you have previous knowledge in React it should be pretty easy for you.

If not, check my other articles, there are some for juniors!

Collapse
 
chantal profile image
Chantal

Checked your articles and l found two.

  1. Building a course landing page with Next.js and Tailwind.
  2. Building a bidding system with Next.js.

I can start with these right?

Thread Thread
 
nevodavid profile image
Nevo David

Yes, start with a bidding system, it's more detailed and contains a Youtube video also.

Thread Thread
 
chantal profile image
Chantal

Thanks, @nevodavid! Your effort for helping me is greatly appreciated.

Collapse
 
mfts profile image
Marc Seitz

Great article Nevo! Well written and lots of helpful snippets regarding Notion and Stripe. The hardest for me is to thoroughly document the process, you did a great job 👏🎉

Collapse
 
nevodavid profile image
Nevo David

Thank you so much!
Which process do you want to document? :)

Collapse
 
mfts profile image
Marc Seitz

Just in general, I’m writing aimilar articles like you (thanks for the inspo 🤩)

Currently writing something about analytics. Stay tuned 😉

Thread Thread
 
nevodavid profile image
Nevo David

I am waiting ! 🏆

Collapse
 
efrenmarin profile image
Efren Marin

Really well thought out article! I also just learned that Notion is a great way to populate website content through their API.

Keep up these articles!

Collapse
 
nevodavid profile image
Nevo David

Thank you so much! I'm glad you enjoyed it!

Collapse
 
matijasos profile image
Matija Sosic • Edited

Awesome stuff! The Notion integration is a super interesting idea.

Collapse
 
nevodavid profile image
Nevo David

I'm glad you like it!
That's my new best CMS 🤣

Collapse
 
sumitsaurabh927 profile image
Sumit Saurabh

Loved this article!

Anyone can read this and build a pricing page with all the bells and whistles like pricing tiers and seamless checkout.

Good stuff! 👏👏

Collapse
 
nevodavid profile image
Nevo David

Thank you so much Sumit! 🏆

I believe this is the common practice of most pricing pages!

Collapse
 
oba2311 profile image
Omer Ben Ami

Love these hands on pieces! Keep going @nevodavid !

Collapse
 
nevodavid profile image
Nevo David

Thank you brother!

Collapse
 
nevodavid profile image
Nevo David

What CMS do you use today?

Collapse
 
manan30 profile image
Manan Joshi

For your pricing pages give this a try pricingch.art

Collapse
 
nevodavid profile image
Nevo David

Lookin' good!

Collapse
 
nevodavid profile image
Nevo David

Which payment provider do you use today?

Collapse
 
mtrabelsi profile image
Marwen Trabelsi • Edited

You checkout is not secure, you are getting and trusting the frontend to send the price to your api for session creation, the price should be calculated/derived inside the api for session creation. Not from the client 😊😇

Collapse
 
fjranggara profile image
fjranggara

NextJS, I love it