After creating an account, the user will be given the free user
role, as we've defined in the prisma.schema
.
1model User {
2 id String @id @default(cuid())
3 name String?
4 email String? @unique
5 emailVerified DateTime?
6 image String?
7 role String? @default("free user") // Add a new "role" column
8 accounts Account[]
9 sessions Session[]
10
11 stripeCustomerId String? @unique
12 subscriptionId String?
13 subscriptionStatus String?
14
15 createdAt DateTime @default(now())
16 updatedAt DateTime @updatedAt
17}
In order for a free user
to become a paid user
, they must be subscribed to our SaaS platform. In this lesson, we are going to discuss how to implement the subscription logic using Stripe.
Creating a product
First of all, you need to set up a new product on Stripe. Go to https://dashboard.stripe.com/ and navigate to the product catalogue page:
Click on the Add a product button to create a new product. You will be asked to provide some information about the product:
Choose your desired product name, and note that this name will be displayed to the user when they checkout.
Pay attention to the lower half of the page. It is where we set up the pricing model for the product. It is possible for the product to have multiple pricing models, but for now, we are starting with a monthly subscription payment model.
Make sure you're choosing Recurring, and the Billing period should be Monthly. The Amount can be anything you want.
After everything is configured correctly, click the Add product button to confirm. And a new product should be created successfully:
Click on the product to view the details, and you should see the pricing model we just configured:
Open the pricing and copy the pricing ID located on the top right corner of the page:
Save the pricing ID into your .env
file.
1. . .
2
3STRIPE_SECRET_KEY="<stripe_secret_key>"
4STRIPE_PRICE_KEY_MONTH="<price_id>"
We are naming it STRIPE_PRICE_KEY_MONTH
to differentiate with the annual and onetime payment models we are going to create later.
Building the pricing page
Go back to our SaaS project, and it is time to implement the logic that allows the user to purchase a subscription.
This feature requires several separate components to work together. In the frontend, there should be a pricing page with a checkout button. When the user clicks on the button, a request will be sent to an API handler.
The API handler checks if the user is authenticated, and if not, an error message should be returned.
If the user is authenticated, a request will then be sent to Stripe. If Stripe processes everything successfully, it will return a checkout URL back to our SaaS.
Visiting this URL will display the Checkout page hosted by Stripe, which looks like this:
This page allows the user to provide their payment information and purchasing our product.
This process is illustrated in the diagram below:
Let's start with pricing page:
1src/app
2├── api
3├── check-your-email
4├── dashboard
5├── favicon.ico
6├── globals.css
7├── layout.jsx
8├── page.jsx
9├── pricing
10│ └── page.jsx <===== The pricing page
11└── signin
pricing/page.jsx
1import StripeButton from "@/components/stripeButton";
2
3export default function PricingPage() {
4 return (
5 <div className="bg-gray-50 py-12">
6 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
7 <div className="text-center">
8 <h2 className="text-3xl font-bold text-gray-900 sm:text-4xl">
9 Pricing Plans
10 </h2>
11 </div>
12
13 <div className="mt-16 space-y-8 lg:space-y-0 lg:grid lg:grid-cols-3 lg:gap-x-8">
14 <div className="rounded-lg bg-white p-8 text-center shadow-md">
15 <h3 className="text-2xl font-bold text-gray-900">Monthly Plan</h3>
16 <p className="mt-4 text-gray-600">
17 Full access to all course materials.
18 </p>
19 <div className="mt-6">
20 <span className="text-4xl font-bold text-gray-900">$29</span>
21 <span className="text-gray-600">/month</span>
22 </div>
23 <ul className="mt-6 space-y-4">. . .</ul>
24 <div className="mt-8">
25 <StripeButton plan={"monthly"} type={"checkout"} />
26 </div>
27 </div>
28 . . .
29 </div>
30 </div>
31 </div>
32 );
33}
For now, we are only going to have the monthly subscription option.
Pay attention to line 1 and line 25, notice that we are using a custom StripeButton
component. This design has several crucial benefits.
First, in the next a few lessons, we are going to add a few more Stripe related button elements, used for cancelling and resuming the subscription, and also for annual and onetime plans.
These button needs to be embedded into different parts of the app, and creating a dedicated component allows us to consolidate all the Stripe handling logic together.
And also, besides the StripeButton
, we are going to need many different buttons for different purposes in our SaaS. They have the same style, but different functions.
To better manage everything, we are going to create base Button
component with the basic styles, and have our StripeButton
wrap around it to provide the features.
1src
2├── app
3├── components
4│ ├── email-template.jsx
5│ ├── footer.jsx
6│ ├── navbar.jsx
7│ ├── stripeButton.jsx <===== Stripe button here
8│ └── ui
9│ └── button.jsx <===== Button styles here
10└── libs
Creating the Button component
For the base Button component, we are creating two different styles, primary
and danger
.
components/ui/button.jsx
1"use client";
2
3export default function Button({
4 style = "primary",
5 children,
6 loading = false,
7 className = "",
8 ...props
9}) {
10 const baseStyles =
11 "inline-block text-gray-100 text-sm font-semibold rounded-md border-2 px-4 py-2 text-center transition-all duration-150 focus:outline-none focus:ring-2";
12
13 let styleClass = "";
14 if (style === "primary") {
15 styleClass =
16 "bg-blue-600 border-blue-600 hover:bg-blue-800 hover:border-blue-800 focus:ring-blue-300";
17 } else if (style === "danger") {
18 styleClass =
19 "bg-red-600 border-red-600 hover:bg-red-800 hover:border-red-800 focus:ring-red-300";
20 }
21
22 const combinedClassName = `${baseStyles} ${styleClass} ${className}`;
23
24 return (
25 <button className={combinedClassName} disabled={loading} {...props}>
26 {children}
27 {loading && (
28 <svg
29 className="animate-spin h-5 w-5 text-white inline ml-2"
30 xmlns="http://www.w3.org/2000/svg"
31 fill="none"
32 viewBox="0 0 24 24">
33 . . .
34 </svg>
35 )}
36 </button>
37 );
38}
From lines 10 to 22, we have the baseStyles
for the button, and two different styleClass
es depending on the value of style
. And combinedClassName puts everything together.
Also notice that from lines 25 to 36, we also implemented a loading state for the button. When loading
is true, the button will be disabled, and a spinner will be displayed indicating something is being loaded.
Creating the StripeButton component
Next, we'll have the StripeButton
, which should be a wrapper around the Button
.
components/stripeButton.jsx
1"use client";
2
3import Button from "@/components/ui/button";
4import { useState } from "react";
5
6export default function StripeButton({ type, plan }) {
7 const [loading, setLoading] = useState(false);
8 const [message, setMessage] = useState("");
9 const [confirmOpen, setConfirmOpen] = useState(false);
10
11 async function checkout(plan) {
12 setLoading(true);
13
14 const response = await fetch(`/api/stripe/${plan}/checkout`, {
15 method: "POST",
16 headers: {
17 "Content-Type": "application/json",
18 },
19 });
20
21 setLoading(false);
22
23 const data = await response.json();
24 if (response.ok) {
25 window.open(data.url, "_target");
26 } else {
27 setMessage(data.message);
28 }
29 }
30
31 return (
32 <>
33 {type === "checkout" && (
34 <Button
35 style="primary"
36 loading={loading}
37 className="w-full text-center"
38 onClick={() => checkout(plan)}>
39 Get this plan
40 </Button>
41 )}
42
43 <p>{message}</p>
44 </>
45 );
46}
This StripeButton
takes two input arguments, type
and plan
.
type
indicated the button's function, which can be checkout, cancel, or resume. plan
indicates the corresponding pricing plan that the button will be working with, such as monthly, annual, and onetime.
For now, we are only considering one scenario, and that is when plan
is monthly, and when type
is checkout.
Line 38, when the button is clicked, the checkout()
method will be executed:
1async function checkout(plan) {
2 setLoading(true);
3
4 const response = await fetch(`/api/stripe/${plan}/checkout`, {
5 method: "POST",
6 headers: {
7 "Content-Type": "application/json",
8 },
9 });
10
11 setLoading(false);
12
13 if (response.ok) {
14 const data = await response.json();
15 window.open(data.url, "_target");
16 } else {
17 const data = await response.json();
18 setMessage(data.message);
19 }
20}
Line 2, at the beginning, loading
will be set to true
, and the button will be placed in a loading state.
Line 4 to 9, a request is then sent to the API endpoint, which we are going to set up later.
Line 11, after the request is sent, and a response is retrieved, loading
will be reset to false
, and the button will return to normal.
Line 13 to 18, if the response indicates that everything is OK, the response body will be retrieved, and we are going to access the URL returned by Stripe, and open it in a new browser tab.
If the response is not OK, an error message will be displayed.