AWSNext.jsDynamoDB | 50 Min Read

How to Build a SaaS Product with Next.js App Router, Clerk, AWS DynamoDB and Stripe

Learn how to build a complete SaaS product from start to finish using Next.js App Router, AWS DynamoDB as well as Clerk for authentication and Stripe!

Building a SaaS product has many benefits, you can develop your business skills, you can learn new development skills and if your product is good, you can make a good income from it. So, with more SaaS products entering the market every month, have you ever wondered how they’re created? Or, even better how you could build your own?

If so, don’t worry because I’ve got you covered! In this post, we’re going to build an example SaaS product called Clicky using Next.js (app router), Stripe, Clerk, and AWS. Clicky is a simple SaaS product that allows users to sign up using an email address and password and allows them to click a button multiple times up to their plan’s limit before prompting them to upgrade.

This is of course a very simplified example of a SaaS product but the functionality and logic we’ll be implementing is the same for more complicated products as well. So, after this tutorial, you should have a working SaaS product that you can expand and adapt to your needs so let’s jump into it!

Project Overview

Let’s start by taking a quick overview of Clicky, the technologies we’ll be using, and the plans we’ll be offering to users.

Technologies

In terms of the technologies we’ll be using, the core of the app will be built using Next.js 13 using the app router, we’ll be using TailwindCSS for styling as well as SWR for client-side data fetching and React Hook Form for controlling our form’s data and state on the sign in, sign up, and verification pages.

As mentioned earlier, we’ll be using Clerk as our authentication provider. For the purposes of this tutorial, we’ll just be using email address and password but you can easily expand this with Clerk to use a whole host of other options such as social providers like Google. Finally, we’ll be using AWS DynamoDB for our database and we’ll be using Stripe to handle the subscriptions and taking of payment from users.

Clicky’s Plans

As we covered in the intro to this post, Clicky is a basic app that offers one feature, the ability to click a button a given number of times; the limit for which is controlled by the plan you’re on.

When a user signs up to Clicky they’ll automatically be added to the Free plan which allows them to click the button 3 times. Once they reach this limit, they’ll be prompted to upgrade to either the Pro or Premium plan which will give them 10 clicks or Unlimited clicks respectively.

Whenever a user hits the limit of their current plan (Free or Pro), they’ll be prompted to upgrade to one of the plans above their current one. In the case of the Free plan, this is done by purchasing a subscription to one of the paid ones using Stripe Payment Links. However, for the Pro plan, they’ll be prompted to use the Stripe Customer Portal to upgrade their subscription to the Premium plan.

Inside the customer portal, we’ll also configure the ability for users to cancel their current subscription and plan and return to the free plan or be able to downgrade to a lower-paid plan (premium to pro).

NOTE: For this tutorial, we’re going to configure the subscriptions to cancel immediately so it’s easier for us to test the functionality but for an actual SaaS product, you’ll likely want to have it configured to cancel at the end of the billing period. I’ll call this setting out when we configure it during the billing portal configurations step.

Prerequisites

Before, getting started with the tutorial portion itself, I want to cover a few prerequisites. As mentioned, we’ll be using AWS DynamoDB for our database, to deploy this I’m going to be using the AWS CDK and CLI. So, I’m going to assume that you already have an AWS account with the CDK, and CLI already configured on your machine and you have an understanding of using them. If not, you can check out my TikTok tutorial here showing how to get started with the AWS CDK in under 60 seconds! You can also read the AWS documentation on setting up and using the CLI and CDK.

I’m also going to assume you have a Stripe account created (you only need access to test mode for this tutorial) and that you’ve created a Clerk account to use with this project. So, if you don’t already have those accounts, take a moment to create them before continuing with this tutorial. Don’t worry about configuring anything in them for now as we’ll handle all of that during the tutorial.

With those prerequisites out of the way, let’s get started with the tutorial and start building a SaaS product!

Initialising Our Application

To get started, we’ll need to create a new Next.js project, we can do this by using the CLI command `npx create-next-app@latest --ts` and answering the prompts it gives to us. Here are the answers I used.

1What is your project named? YOUR_PROJECT_NAME
2Would you like to use ESLint? Yes
3Would you like to use Tailwind CSS? Yes
4Would you like to use `src/` directory? No
5Would you like to use App Router? (recommended) Yes
6Would you like to customize the default import alias? No

Once that command is finished and our new Next.js project is created, we can `cd` into it and we’ll be ready to move on to the next step and create our DynamoDB database.

Setting up AWS DynamoDB

Once you’re in your Next.js project’s directory, create a new folder in the root of the project called `cdk` and `cd` into it. We then want to run the command `cdk init app --language typescript` to create a new CDK project inside the directory, this is how we’ll define and deploy our DynamoDB database.

After your CDK project has finished initialising, open up the stack definition file inside the `lib` directory and add the below code to the class constructor that is already in the file, making sure to update the `databaseName` variable with the name you’d like to assign to your database.

./cdk/lib/*-stack.ts
1const databaseName = "YOUR_TABLE_NAME";
2
3new Table(this, databaseName, {
4 tableName: databaseName,
5 partitionKey: { name: "pk", type: AttributeType.STRING },
6 sortKey: { name: "sk", type: AttributeType.STRING },
7 removalPolicy: RemovalPolicy.DESTROY,
8 billingMode: BillingMode.PAY_PER_REQUEST,
9});
ts

So, after adding the code, your stack definition file should look similar to this.

./cdk/lib/*-stack.ts
1import { RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
2import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
3import { Construct } from "constructs";
4
5export class CdkStack extends Stack {
6 constructor(scope: Construct, id: string, props?: StackProps) {
7 super(scope, id, props);
8
9 const databaseName = "YOUR_TABLE_NAME";
10
11 new Table(this, databaseName, {
12 tableName: databaseName,
13 partitionKey: { name: "pk", type: AttributeType.STRING },
14 sortKey: { name: "sk", type: AttributeType.STRING },
15 removalPolicy: RemovalPolicy.DESTROY,
16 billingMode: BillingMode.PAY_PER_REQUEST,
17 });
18 }
19}
ts

With that added to our stack definition file, we’re ready to deploy our CDK stack which we can then do by running the command `cdk deploy` from the `cdk` directory and accepting any prompts we’re given.

Connecting Next.js and DynamoDB

After our DynamoDB table is finished deploying, we need to add some environment variables, NPM packages, and configuration files to our Next.js project so we’re able to communicate with our new database. To get started, create a new `.env.local` file in the root of your Next.js project (if you don’t already have one) and add the below environment variables to it.

.env.local
1# AWS API KEYS
2AWS_ACCOUNT_ACCESS_KEY="YOUR_AWS_ACCOUNT_ACCESS_KEY"
3AWS_ACCOUNT_SECRET_KEY="YOUR_AWS_ACCOUNT_SECRET_KEY"
4AWS_ACCOUNT_REGION="YOUR_TARGET_AWS_REGION"
5DB_TABLE_NAME="YOUR_TABLE_NAME"

You can then populate the values of these variables by switching out the placeholders with the correct values for your account. On the topic of environment variables if you’d like to learn how to resolve the TypeScript issue with them being typed as potentially `undefined` then you can follow this guide I wrote.

Finally, to finish off our Next.js connection to DynamoDB, we’ll need to install a couple of NPM packages which we can do with the command `npm i @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb`. With these installed, the last thing we need to do is create the configuration file that will contain our DynamoDB client. To add this, create a new file in the root of the project called `config.ts` and add the below code to it.

./config.ts
1import { DynamoDB } from "@aws-sdk/client-dynamodb";
2import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb";
3
4const awsCredetnials = {
5 accessKeyId: process.env.AWS_ACCOUNT_ACCESS_KEY,
6 secretAccessKey: process.env.AWS_ACCOUNT_SECRET_KEY,
7};
8
9const dynamoConfig = {
10 region: process.env.AWS_ACCOUNT_REGION,
11 credentials: awsCredetnials,
12} as {
13 credentials: {
14 accessKeyId: string;
15 secretAccessKey: string;
16 };
17 region: string;
18};
19
20const db = DynamoDBDocument.from(new DynamoDB(dynamoConfig), {
21 marshallOptions: {
22 convertEmptyValues: true,
23 removeUndefinedValues: true,
24 convertClassInstanceToMap: false,
25 },
26});
27
28export { db };
ts

With this file created, we can now use `db` to send commands to our database to perform the various reads and writes we’ll need in our application. And, with that, we’ve completed everything we need for DynamoDB and connecting it to Next.js so let’s move on to setting up our Clerk instance.

Clerk Setup

Fortunately for us, Clerk makes their setup a dream and easy to do. To get started, log into your Clerk account and create a new application if you don’t already have one. Then you’ll want to click on the “User & Authentication” sidebar menu followed by “Email, Phone, Username” and ensure “Email address” is checked under “Contact information” as well as both “Password” and “Email verification code” under “Authentication factors” are checked as well.

After, ensuring all of the above options are configured correctly, head to the “API Keys” page in Clerk and select “Next.js” from the dropdown before then copying both of the environment variables shown to you. Let’s now add them to our `.env.local` file in our Next.js project like so.

.env.local
1# CLERK ENVS
2NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="YOUR_CLERK_PUBLISHABLE_KEY"
3CLERK_SECRET_KEY="YOUR_CLERK_SECRET_KEY"
4
5NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
6NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
7
8# AWS API KEYS
9AWS_ACCOUNT_ACCESS_KEY="YOUR_AWS_ACCOUNT_ACCESS_KEY"
10AWS_ACCOUNT_SECRET_KEY="YOUR_AWS_ACCOUNT_SECRET_KEY"
11AWS_ACCOUNT_REGION="YOUR_TARGET_AWS_REGION"
12DB_TABLE_NAME="YOUR_TABLE_NAME"

We’ve also added two more environment variables alongside the ones we got from the Clerk dashboard, these are `NEXT_PUBLIC_CLERK_SIGN_IN_URL` and `NEXT_PUBLIC_CLERK_SIGN_UP_URL` and they tell Clerk the URLs that our sign-in and sign-up pages will be located at.

After configuring our environment variables, we need to install the Clerk Next.js package to our project using the command `npm i @clerk/nextjs`. And, then we need to wrap our application in the `ClerkProvider` component which we can do in our `app/layout.tsx` file. Below is the complete `app/layout.tsx` file that includes the `ClerkProvider` as well as some base styles, fonts, and headings for the app to use.

./app/layout.tsx
1import { ClerkProvider } from "@clerk/nextjs";
2import "./globals.css";
3import type { Metadata } from "next";
4import { Karla } from "next/font/google";
5
6const karla = Karla({ subsets: ["latin"] });
7
8export const metadata: Metadata = {
9 title: "Create Next App",
10 description: "Generated by create next app",
11};
12
13export default function RootLayout({
14 children,
15}: {
16 children: React.ReactNode;
17}) {
18 return (
19 <ClerkProvider>
20 <html lang="en">
21 <body
22 className={`${karla.className} min-h-screen bg-slate-100 md:overflow-hidden`}
23 >
24 <div className="flex flex-col items-center justify-center gap-12 min-h-screen m-4">
25 <div className="flex flex-col gap-8 w-full max-w-3xl">
26 <div className="flex flex-col">
27 <h2 className="text-4xl font-bold">Clicky</h2>
28 <p className="text-xl italic font-light">
29 The best app for clicking
30 </p>
31 </div>
32 <div className="max-w-3xl">{children}</div>
33 </div>
34 </div>
35 </body>
36 </html>
37 </ClerkProvider>
38 );
39}
tsx

Also while we’re talking about setting up styles, let’s update your `./app/global.css` file to just contain the Tailwind styles like below as well as update our `./tailwind.config.ts` file to look like below as well. You’ll need to also install `@tailwindcss/forms` as we use that to add some base styles to our form inputs.

./app/global.css
1@tailwind base;
2@tailwind components;
3@tailwind utilities;
css
./tailwind.config.ts
1import type { Config } from "tailwindcss";
2
3const config: Config = {
4 content: [
5 "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 ],
9 theme: {},
10 // eslint-disable-next-line global-require
11 plugins: [require("@tailwindcss/forms")],
12};
13
14export default config;
ts

Finally, to finish off the setup of Clerk, we need to create a new middleware for our application which can be done by creating a new `middleware.ts` file in the root of the project and adding the below code.

./middleware.ts
1// ./middleware.ts
2
3import { authMiddleware } from "@clerk/nextjs";
4
5export default authMiddleware({
6 publicRoutes: ["/api/webhooks/stripe"],
7});
8
9export const config = {
10 matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
11};
ts

NOTE: If you’re interested in learning more about the `ClerkProvider` or the `middleware.ts` file we configured, you can read about them on the Clerk documentation.

And, now with Clerk fully set up, we’re now ready to move on to configuring our authentication pages in our project so users can sign up and use our application.

Building Our Authentication Pages

Before we jump into building our authentication pages let’s take a moment to understand the pages we need and will be building. There are three pages in total, the first being the `sign-in` page and is where users will sign into their account if they already have one.

Secondly, we have the `sign-up` page which is where users will go if they wish to sign up for a new account. And, lastly, we have the `sign-up/verify-email-address` page, this is the page users will be redirected to after they fill in the sign-up form to verify their email address by entering a 6-digit code that has been emailed to them.

So, with that now out of the way, we now know what we’ll be building so let’s start building our authentication pages, starting with the `sign-up` page.

Sign Up Page

On our sign-up page, we’ll be displaying a form for users to fill in with their desired email address and password; for this form, we’ll be using the NPM package `react-hook-form` to handle its state and data so we’ll need to install that first via the command `npm i react-hook-form`.

Once that is installed, we can get started on creating the form we just mentioned, to do this create a new file at `./components/auth/SignUpForm.tsx` and add in the below code.

./components/auth/SignUpForm.tsx
1"use client";
2
3import { useSignUp } from "@clerk/nextjs";
4import { useRouter } from "next/navigation";
5import { useState } from "react";
6import { useForm } from "react-hook-form";
7
8interface SignUpFormValues {
9 email: string;
10 password: string;
11}
12
13export default function SignUpForm() {
14 const router = useRouter();
15 const [error, setError] = useState<string | null>(null);
16 const { register, handleSubmit } = useForm<SignUpFormValues>();
17 const { signUp } = useSignUp();
18
19 const onSubmit = async ({ email, password }: SignUpFormValues) => {
20 try {
21 if (!signUp) {
22 // eslint-disable-next-line no-console
23 console.log("Clerk sign up not avaialble");
24 return null;
25 }
26
27 const response = await signUp.create({
28 emailAddress: email,
29 password,
30 });
31
32 if (response.unverifiedFields.includes("email_address")) {
33 await signUp.prepareEmailAddressVerification({
34 strategy: "email_code",
35 });
36
37 router.push("/sign-up/verify-email-address");
38 }
39 } catch (err) {
40 setError("Email address already in use");
41 // eslint-disable-next-line no-console
42 console.error(err);
43 }
44 };
45
46 return (
47 <form
48 className="flex flex-col items-center justify-center w-full"
49 onSubmit={handleSubmit(onSubmit)}
50 >
51 <div className="flex flex-col gap-4 w-full">
52 <h1 className="text-xl font-bold">Sign Up</h1>
53 <div className="flex flex-col">
54 <label htmlFor="email">Email Address</label>
55 <input
56 type="text"
57 {...register("email", { required: true })}
58 className="text-lg p-2 px-3 rounded-md"
59 />
60 </div>
61 <div className="flex flex-col">
62 <label htmlFor="password">Password</label>
63 <input
64 type="password"
65 {...register("password", { required: true })}
66 className="text-lg p-2 px-3 rounded-md"
67 />
68 </div>
69 <button
70 type="submit"
71 className="bg-blue-400 text-white font-medium py-2 rounded-md"
72 >
73 Continue
74 </button>
75 {error ? <p className="text-red-500">{error}</p> : null}
76 </div>
77 </form>
78 );
79}
tsx

This form has a couple of parts, the UI portion that returns the form with the email address and password fields as well as the `onSubmit` handler function that is triggered when the user presses the “Continue” button to submit the form.

Inside the `onSubmit` function, we make use of the `useSignUp` hook from Clerk which allows us to perform the initial user sign-up and then check if their email address is verified or not. If the email address isn’t verified, we then call the `prepareEmailAddressVerification` method to send a verification code to the supplied email address and then redirect the user to our `verify-email-address` page for them to complete the verification process.

It’s also worth noting that because we use the `useSignUp` hook and `useForm` from `react-hook-form`, we need to make this component a client component by adding `'use client';` to the top of the file. This is also true for our two other authentication form components that we’ll be creating in a moment for the `sign-in` and `verify-email-address` pages.

To finish off the `sign-up` page, we just need to create the page itself which we can do by creating a new file at `./app/sign-up/page.tsx` and adding in the below code.

./app/sign-up/page.tsx
1import Link from "next/link";
2import SignUpForm from "@/components/auth/SignUpForm";
3
4export default function Page() {
5 return (
6 <div className="flex flex-col gap-4">
7 <SignUpForm />
8 <div className="flex flex-row items-center gap-1">
9 <p>Have an account already?</p>
10 <Link href="/sign-in" className="text-blue-600 font-bold">
11 Sign In
12 </Link>
13 </div>
14 </div>
15 );
16}
tsx

As you can see this page, is largely for styling and rendering the form component we just created as that contains all of the logic required for actually signing the user up.

Verify Email Address Page

Let’s now finish off our sign-up flow by creating the page and form component for our verify email address page that will allow users to enter the code they received to verify their email address and allow them to sign in.

However, before we can create the page and component that will allow the user to verify their email address, we first need to create a new API route in our application. This API route will handle the creation of the user in our database so we can store data against them such as their current Stripe plan as well as their usage of features so we can see if they’ve hit their limit or not.

To create this API route, create a new file at `./app/api/user/route.ts` and then add the below code to it.

./app/api/user/route.ts
1import { PutCommand } from "@aws-sdk/lib-dynamodb";
2import { currentUser } from "@clerk/nextjs";
3import { NextResponse } from "next/server";
4import { db } from "@/config";
5
6export async function POST() {
7 const currentUserData = await currentUser();
8
9 if (!currentUserData) {
10 return NextResponse.error();
11 }
12
13 await db.send(
14 new PutCommand({
15 TableName: process.env.DB_TABLE_NAME,
16 Item: {
17 pk: `USER#${currentUserData?.id}`,
18 sk: `USER#${currentUserData?.id}`,
19 email: currentUserData.emailAddresses[0].emailAddress,
20 createdAt: currentUserData.createdAt,
21 plan: "FREE",
22 buttonClicks: 0,
23 },
24 })
25 );
26
27 return NextResponse.json({});
28}
ts

In this API route, we get the current user and then use the `db` client we created earlier to add a new item to our database for that user which assigns them to the “FREE” plan and defaults their usage of `buttonClicks` to 0.

With this API route now created, we’re ready to create the component that will consume it. To get started, create a new file at `./components/auth/CodeForm.tsx` and add the below code into it.

./components/auth/CodeForm.tsx
1"use client";
2
3import { useSignUp } from "@clerk/nextjs";
4import { useRouter } from "next/navigation";
5import { useForm } from "react-hook-form";
6
7export default function CodeForm() {
8 const router = useRouter();
9 const { register, handleSubmit } = useForm<{
10 code: string;
11 }>();
12 const { signUp, setActive } = useSignUp();
13
14 const onSubmit = async ({ code }: { code: string }) => {
15 try {
16 if (!signUp) {
17 // eslint-disable-next-line no-console
18 console.log("Clerk sign up not avaialble");
19 return null;
20 }
21
22 const response = await signUp.attemptEmailAddressVerification({
23 code,
24 });
25
26 await setActive({ session: response.createdSessionId });
27
28 await fetch("/api/user", {
29 method: "POST",
30 });
31
32 router.push("/");
33 } catch (error) {
34 // eslint-disable-next-line no-console
35 console.error(error);
36 }
37 };
38
39 return (
40 <form
41 className="flex flex-col items-center justify-center w-full"
42 onSubmit={handleSubmit(onSubmit)}
43 >
44 <div className="flex flex-col gap-4 w-full">
45 <h1 className="text-xl font-bold">Verify your email address</h1>
46 <div className="flex flex-col">
47 <label htmlFor="code">Verification Code</label>
48 <input
49 type="text"
50 {...register("code", { required: true })}
51 className="text-lg p-2 px-3 rounded-md"
52 />
53 </div>
54 <button
55 type="submit"
56 className="bg-blue-400 text-white font-medium py-2 rounded-md"
57 >
58 Continue
59 </button>
60 </div>
61 </form>
62 );
63}
tsx

In this client component, we render out a form for the user to input the verification code they received in their emails and then once they push “Continue” we trigger the `onSubmit` handler function.

In this function, we use the `useSignUp` hook from Clerk again but this time, we use it to verify the email address they provided previously and then create a new session for them before setting the active session and making a POST request to our new API route to create the user in the database. We then finish off the sign-up process by redirecting the user to the home page of our application as they are now authenticated and signed up to our app.

With our `CodeForm` component created, the last thing we need to do is to create the verify email address page itself so to do that create a new file at `./app/sign-up/verify-email-address/page.tsx` and add in the below code.

./app/sign-up/verify-email-address/page.tsx
1import CodeForm from "@/components/auth/CodeForm";
2
3export default function Page() {
4 return <CodeForm />;
5}
tsx

Much like the sign-up page we did previously there isn’t much happening in this file at all besides rendering out the client component we just created for our form.

Sign-In Page

With our sign-up and verify email address pages complete, our sign-up flow is finished. So, all that’s left to do is create our sign-in flow. Luckily for us, the sign-in flow is simpler and only contains one page and component so let’s create them now.

Firstly, let’s create our sign-in form component by creating a new file at `./components/auth/SignInForm.tsx` and adding the below code.

./components/auth/SignInForm.tsx
1"use client";
2
3import { useSignIn } from "@clerk/nextjs";
4import { useRouter } from "next/navigation";
5import { useForm } from "react-hook-form";
6
7interface SignUpFormValues {
8 email: string;
9 password: string;
10}
11
12export default function SignInForm() {
13 const router = useRouter();
14 const { register, handleSubmit } = useForm<SignUpFormValues>();
15 const { signIn, setActive } = useSignIn();
16
17 const onSubmit = async ({ email, password }: SignUpFormValues) => {
18 try {
19 if (!signIn) {
20 // eslint-disable-next-line no-console
21 console.log("Clerk sign in not avaialble");
22 return null;
23 }
24
25 const response = await signIn.create({
26 identifier: email,
27 password,
28 });
29
30 await setActive({ session: response.createdSessionId });
31 router.push("/");
32 } catch (error) {
33 // eslint-disable-next-line no-console
34 console.error(error);
35 }
36 };
37
38 return (
39 <form
40 className="flex flex-col items-center justify-center w-full"
41 onSubmit={handleSubmit(onSubmit)}
42 >
43 <div className="flex flex-col gap-4 w-full">
44 <h1 className="text-2xl font-bold">Sign In</h1>
45 <div className="flex flex-col">
46 <label htmlFor="email">Email Address</label>
47 <input
48 type="text"
49 {...register("email", { required: true })}
50 className="text-lg p-2 px-3 rounded-md"
51 />
52 </div>
53 <div className="flex flex-col">
54 <label htmlFor="password">Password</label>
55 <input
56 type="password"
57 {...register("password", { required: true })}
58 className="text-lg p-2 px-3 rounded-md"
59 />
60 </div>
61 <button
62 type="submit"
63 className="bg-blue-400 text-white font-medium py-2 rounded-md"
64 >
65 Continue
66 </button>
67 </div>
68 </form>
69 );
70}
tsx

This component is similar to the two we previously covered but the big difference is this time we make use of the `useSignIn` hook from Clerk to sign the user into their account and create a new session for them before setting the active session using `setActive` from the `useSignIn` hook. We then complete the sign-in process by redirecting them to the home page of the application.

Finally, to complete our sign-in flow and finish off our authentication pages, we just need to create our sign-in page by creating a new file at `./app/sign-in/page.tsx` and adding the below code.

./app/sign-in/page.tsx
1import Link from "next/link";
2import SignInForm from "@/components/auth/SignInForm";
3
4export default function Page() {
5 return (
6 <div className="flex flex-col gap-4">
7 <SignInForm />
8 <div className="flex flex-row items-center gap-1">
9 <p>Don't have an account?</p>
10 <Link href="/sign-up" className="text-blue-600 font-bold">
11 Sign Up
12 </Link>
13 </div>
14 </div>
15 );
16}
tsx

And, with that, we’ve created all of our authentication pages and are ready to move on to configuring Stripe!

Configuring Stripe

Creating Our Products

After logging into your Stripe account, make sure you’re in test mode by using the toggle in the top right of the page and then you’ll want to head over to the “Product Catalogue” page by using the search bar at the top of the screen.

Once on the “Product Catalogue” page, we’ll need to create two new products, one for each of the tiers (Pro and Premium) we’re going to offer to our customers. To create these, click on the “Add product” button and then use the below details for the form.

1Name: Clicky Pro
2Additional Options > Metadata:
3 Key: tier
4 Value: PRO
5Pricing Model: Standard pricing
6Price: 5 USD
7Recurring
8Billing period: Monthly

Then click on the “Save and add more” button and repeat the process for the premium tier version of the product using the values below.

1Name: Clicky Premium
2Additional Options > Metadata:
3 Key: tier
4 Value: PREMIUM
5Pricing Model: Standard pricing
6Price: 10 USD
7Recurring
8Billing period: Monthly

At this point, we should now have two products in Stripe and each of those should have a monthly plan that the user can subscribe to. We also added some metadata to each of the products to store the name of the tier that product is for which we’ll use later on when we configure our app to process Stripe’s webhooks.

Stripe CLI

Because we’ll be using Stripe’s webhooks to process the events that occur on our account such as when a user subscribes to a plan, we’ll need to configure the Stripe CLI on our local machine to allow us to listen to the webhook events during development. To set up the Stripe CLI on your machine, you can follow Stripe’s documentation here.

Adding Stripe’s ENVs

With the products created and the Stripe CLI configured, we just need to add some more ENVs to our Next.js project to allow everything to function correctly. Below are the ENVs you’ll need to add to your `.env.local` file.

.env.local
1STRIPE_SECRET_KEY = "";
2STRIPE_WEBHOOK_SECRET = "";
3
4PRO_PROD_ID = "";
5PREMIUM_PROD_ID = "";
6
7PRO_PRICE_ID = "";
8PREMIUM_PRICE_ID = "";

You can retrieve your `STRIPE_SECRET_KEY` by heading over to the “Developers” page on your Stripe dashboard. While on the Stripe dashboard, you can also get the values for `PRO_PROD_ID`, `PREMIUM_PROD_ID`, `PRO_PRICE_ID`, and `PREMIUM_PRICE_ID` by going to the “Product Catalogue” page again and copying the values shown for each of the products and the prices on those products.

Finally, for `STRIPE_WEBHOOK_SECRET`, you can get this value by running the command `stripe listen` in your terminal and copying the signing secret value displayed to you.

Adding Functionality

With our authentication pages and Stripe configuration finished, let’s get started on building out the functionality of Clicky. The first thing we’re going to do is to define the plans that are available for our product and their limitations, to do this, create a new file in the root of the project called `constants.ts` and add the below code.

./constants.ts
1import { IPlan } from "./types";
2
3export const PLANS: IPlan[] = [
4 {
5 TIER: "FREE",
6 LIMITATIONS: {
7 BUTTON_CLICKS: 3,
8 },
9 },
10 {
11 TIER: "PRO",
12 PRODUCT_ID: process.env.PRO_PROD_ID,
13 PLAN_ID: process.env.PRO_PRICE_ID,
14 LIMITATIONS: {
15 BUTTON_CLICKS: 10,
16 },
17 },
18 {
19 TIER: "PREMIUM",
20 PRODUCT_ID: process.env.PREMIUM_PROD_ID,
21 PLAN_ID: process.env.PREMIUM_PRICE_ID,
22 LIMITATIONS: {
23 BUTTON_CLICKS: -1,
24 },
25 },
26];
ts

At this point we’ll have an import error in our code so we’ll also need to define the type `IPlan` to resolve the import error. To do this, create a new file called `types.ts` in the root of the project and add the code below.

./types.ts
1import { GetCommandOutput } from "@aws-sdk/lib-dynamodb";
2
3export type IUser = {
4 pk: string;
5 sk: string;
6 email: string;
7 createdAt: string;
8 plan: "FREE" | "PRO" | "PREMIUM";
9 buttonClicks: number;
10 subscriptionStatus?: string;
11 stripeCustomerId: string;
12};
13
14export type IPlan = {
15 TIER: "FREE" | "PRO" | "PREMIUM";
16 LIMITATIONS: {
17 BUTTON_CLICKS: number;
18 };
19 PRODUCT_ID?: string;
20 PLAN_ID?: string;
21};
22
23export type IGetCommandOutput<T> = Omit<GetCommandOutput, "Item"> & {
24 Item?: T;
25};
ts

In this file, we’ve also defined the types for our user in the database which we’ll need in a moment as well as a type for typing the response from a `GetCommand` to DynamoDB. If you’re curious about this type and want to learn more, you can read about it in my blog post here.

Utility Functions

With our types and plans configuration sorted, let’s update our home page to show the current user’s plan and add the functionality for them to sign out via Clerk’s pre-built component. However, before we can do this, we need to create a couple of custom functions that we’ll consume on the home page to fetch the user’s details

The first one we’re going to create is `getCurrentUser` which we’ll use to fetch the current user’s details from the database. To create this, create a new file at `./utils/db/get-current-user.ts` and add in the below code.

./utils/db/get-current-user.ts
1import { GetCommand } from "@aws-sdk/lib-dynamodb";
2import { currentUser } from "@clerk/nextjs";
3import { db } from "@/config";
4import { IGetCommandOutput, IUser } from "@/types";
5
6export default async function getCurrentUser() {
7 const currentUserData = await currentUser();
8
9 if (!currentUserData) {
10 return null;
11 }
12
13 const { Item: user } = (await db.send(
14 new GetCommand({
15 TableName: process.env.DB_TABLE_NAME,
16 Key: {
17 pk: `USER#${currentUserData?.id}`,
18 sk: `USER#${currentUserData?.id}`,
19 },
20 })
21 )) as IGetCommandOutput<IUser>;
22
23 return user;
24}
ts

In this file, we perform a `GetCommand` to our database to retrieve the current user’s data as well as use the `IGetCommandOutput` type we created earlier for typing the return from the database.

The second custom function we’re going to define is `getPlan` which will retrieve the user’s current plan as well as the limitations and data associated with it from our `constants.ts` file. To create this function, create a new file at `./utils/get-plan.ts` and add the below code.

./utils/get-plan.ts
1import { PLANS } from "@/constants";
2import { IPlan } from "@/types";
3
4export default function getPlan(tier: IPlan["TIER"]) {
5 const userPlan = PLANS?.find((plan) => plan.TIER === tier);
6
7 return userPlan;
8}
ts

Adding the API Route

With our utility functions now sorted, let’s add some actual functionality to Clicky! As mentioned earlier, Clicky is a simple app that allows users to click a button a number of times up to the limit their plan dictates which we can see in our `constants.ts` file from earlier (”-1” for the premium plan will be unlimited).

To add this functionality we’re going to create a new client component to render the button itself and a new API route to handle the incrementing of the data for our user in the database. Let’s start by creating the new API route first, to do this, create a new file at `./app/api/clicks/route.ts` and add the below code to it.

./app/api/clicks/route.ts
1import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
2import { NextResponse } from "next/server";
3import { db } from "@/config";
4import getCurrentUser from "@/utils/db/get-current-user";
5import getPlan from "@/utils/get-plan";
6
7export async function POST() {
8 const user = await getCurrentUser();
9
10 if (!user) {
11 return NextResponse.error();
12 }
13
14 const plan = getPlan(user.plan);
15
16 if (!plan) {
17 return NextResponse.error();
18 }
19
20 const { BUTTON_CLICKS: BUTTON_CLICKS_LIMIT } = plan.LIMITATIONS;
21
22 if (BUTTON_CLICKS_LIMIT !== -1 && user.buttonClicks >= BUTTON_CLICKS_LIMIT) {
23 return NextResponse.json(
24 {
25 error: `You have reached your limit of ${BUTTON_CLICKS_LIMIT} button clicks. Please upgrade to continue clicking.`,
26 },
27 {
28 status: 403,
29 }
30 );
31 }
32
33 await db.send(
34 new UpdateCommand({
35 TableName: process.env.DB_TABLE_NAME,
36 Key: {
37 pk: user.pk,
38 sk: user.sk,
39 },
40 UpdateExpression: "ADD buttonClicks :inc",
41 ExpressionAttributeValues: {
42 ":inc": 1,
43 },
44 })
45 );
46
47 return NextResponse.json({ status: 204 });
48}
49
50export async function GET() {
51 const user = await getCurrentUser();
52
53 return NextResponse.json(user?.buttonClicks);
54}
ts

In this file, we define two functions, a `GET` function for retrieving the user’s current click number from the database and then a `POST` function for incrementing the user’s click number by one for every request sent to it.

Inside the `POST` function, we also check if the user has exceeded the limit of their plan or not and return an error response if they have to prevent them from hitting the API directly and incrementing their count without using the UI.

Adding the UI

With our API route now implemented, we just need to add the UI that allows the user to increment their count by pressing a button and also handle the checking of their plan limits and prompting them to upgrade if they hit their limit or have exceeded it.

But, before we can create this file, we need to update our `config.ts` file with a `fetcher` function for `swr` to use in our component for fetching data. So, inside your `config.ts` update the file to add in the `fetcher` function below as well as update the export statement to include it like so. Don’t forget to also install `swr` using `npm i swr`.

./config.ts
1// ...AWS and DyanmoDB config...
2
3const fetcher = async <T,>(url: string) =>
4 fetch(url).then((res) => res.json() as T);
5
6export { db, fetcher };
ts

With this added in, we’re ready to move on to creating the UI for our API route. To do this, create a new file at `./components/Button.tsx` and add the below code to it.

./components/Button.tsx
1"use client";
2
3import useSWR from "swr";
4import { useEffect, useState } from "react";
5import { fetcher } from "@/config";
6import { IPlan } from "@/types";
7
8interface IProps {
9 plan: IPlan;
10 current: number;
11}
12
13export default function Button({ plan, current }: IProps) {
14 const [isLimitReached, setIsLimitReached] = useState(false);
15
16 const { BUTTON_CLICKS: BUTTON_CLICKS_LIMIT } = plan.LIMITATIONS;
17
18 const limitReachedMessage =
19 plan.TIER === "PRO"
20 ? "You have reached the limit of clicks for the pro plan, please upgrade to the premium plan below to keep clicking."
21 : "You have reached the limit of clicks for the free plan, please upgrade to a paid plan below to keep clicking.";
22
23 const { data: currentClicks, mutate } = useSWR(
24 "/api/clicks",
25 fetcher<number>,
26 {
27 fallbackData: current,
28 }
29 );
30
31 async function handleClick() {
32 await fetch("/api/clicks", {
33 method: "POST",
34 });
35 await mutate();
36 }
37
38 useEffect(() => {
39 if (BUTTON_CLICKS_LIMIT !== -1 && currentClicks >= BUTTON_CLICKS_LIMIT) {
40 setIsLimitReached(true);
41 }
42 }, [currentClicks, BUTTON_CLICKS_LIMIT]);
43
44 const limitLabel =
45 BUTTON_CLICKS_LIMIT === -1 ? "Unlimited" : BUTTON_CLICKS_LIMIT;
46
47 return (
48 <>
49 <div className="flex flex-col-reverse md:flex-row justify-between items-center text-xl gap-4 md:gap-0">
50 <p>
51 Current Clicks Used:{" "}
52 <span className="font-bold">
53 {currentClicks}/{limitLabel}
54 </span>
55 </p>
56 <button
57 type="button"
58 onClick={handleClick}
59 disabled={isLimitReached}
60 className="border-blue-400 text-blue-600 hover:bg-blue-200 duration-150 ease-in-out border-2 px-3 py-2 rounded-md font-bold disabled:text-red-600 disabled:border-red-600 disabled:bg-red-200 disabled:cursor-not-allowed"
61 >
62 Click Me
63 </button>
64 </div>
65 {isLimitReached ? (
66 <p className="bg-amber-200 border-amber-400 border-2 text-amber-600 p-4 rounded-md md:text-lg font-bold">
67 {limitReachedMessage}
68 </p>
69 ) : null}
70 </>
71 );
72}
tsx

In this component, we render out a few things; the user’s current click number compared to their limit, the button itself they can click to increment the count, and finally the error message that prompts them to upgrade if they have hit their limit for clicks.

To ensure we’re always using the latest data from the API route, we use the `swr` package to fetch the data from our API route whenever the component renders. We also use the `mutate()` function from `swr` inside our `handleClick` function to re-fetch the data whenever the user clicks the button to increment the count.

Finally, we have a `useEffect` hook that triggers every time the user’s current click number is updated or when their limit changes to update the state that controls if the limit reached message should be displayed.

After adding this component, let’s update our home page to show it as well as some other information about the user and a way to sign out by updating our `./app/page.tsx` file to use the below code.

./app/page.tsx
1import { UserButton } from "@clerk/nextjs";
2import getCurrentUser from "@/utils/db/get-current-user";
3import Button from "@/components/Button";
4import getPlan from "@/utils/get-plan";
5
6export default async function Home() {
7 const user = await getCurrentUser();
8
9 if (!user) {
10 return <p>Loading...</p>;
11 }
12
13 const { buttonClicks } = user;
14 const plan = getPlan(user.plan);
15
16 if (!plan) {
17 return <p>No User Plan Found</p>;
18 }
19
20 return (
21 <div className="flex flex-col gap-6">
22 <div className="flex flex-row py-4 border-b justify-between items-center">
23 <UserButton afterSignOutUrl="/" />
24 <p className="text-xl">
25 Your Current Plan: <span className="font-bold">{user.plan}</span>
26 </p>
27 </div>
28 <Button plan={plan} current={buttonClicks} />
29 </div>
30 );
31}
tsx

With our home page updated, we’ve finished adding the functionality to our app for our features. So, all we need to do now is add in the functionality for Stripe and subscriptions so users can subscribe to one of our plans and receive the limits of that plan in our app as well as be able to manage it via Stripe’s Billing Portal.

Adding Subscriptions

We’re now at a point with Clicky where users can use the product but they’re stuck to the free plan and the limitations that come with it. So, let’s now work on adding in the required logic for users to subscribe to one of our paid plans and be able to access the limitations of those plans.

To do this, we’re going to be using Stripe’s payment links functionality that allows us to generate a link that users can click on and be taken to a Stripe-hosted page to purchase the plan they’ve selected.

To get started with adding this into our app we first need to create a couple of utility functions that will allow us to create a new customer in Stripe before then creating a checkout session for that customer and returning the URL for users to visit. Finally, we’ll create a UI component to render the payment link URL for each plan so let’s get started!

Utility Functions

Let’s get started with adding the utility functions as we mentioned. The first one we’re going to create is `createStripeUserId`, this allows us to create a new customer in our Stripe account using the email address the user provided during sign-up. We’ll then store the ID of the Stripe customer in our database attached to their user data so we can retrieve it later on.

To create this function, create a new file at `./utils/stripe/create-stripe-user-id.ts` and add the below code to it, don’t forget to also install the stripe NPM package by running `npm i stripe` in your terminal.

./utils/stripe/create-stripe-user-id.ts
1import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
2import Stripe from "stripe";
3import { IUser } from "@/types";
4import { db } from "@/config";
5
6interface IProps {
7 user: IUser;
8}
9
10export default async function createStripeUserId({ user }: IProps) {
11 let stripeCustomerId = "";
12
13 const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
14 apiVersion: "2022-11-15",
15 });
16
17 const { data } = await stripe.customers.list({
18 email: user.email,
19 });
20
21 if (!data.length) {
22 const stripeCustomer = await stripe.customers.create({
23 email: user.email,
24 });
25
26 stripeCustomerId = stripeCustomer.id;
27 } else {
28 stripeCustomerId = data?.[0]?.id;
29 }
30
31 await db.send(
32 new UpdateCommand({
33 TableName: process.env.DB_TABLE_NAME,
34 Key: {
35 pk: user.pk,
36 sk: user.sk,
37 },
38 UpdateExpression: "SET stripeCustomerId = :stripeCustomerId",
39 ExpressionAttributeValues: {
40 ":stripeCustomerId": stripeCustomerId,
41 },
42 })
43 );
44
45 return stripeCustomerId;
46}
ts

With that function now created, we can create our next utility function `createCheckoutSession`, this will handle the creation of our checkout session and return the URL for us to display in the UI.

However, before we can create this function we need to make a small addition to our `config.ts` file to define our application’s URL for both local development and production. So, in your `config.ts` file, add the below code underneath the `fetcher` function we defined earlier as well as update the export statement.

./config.ts
1// ...fetcher function and AWS config...
2
3const isProd = process.env.NODE_ENV === "production";
4const appUrl = !isProd ? "http://localhost:3000" : "YOUR_DEPLOYED_SITE_URL";
5
6export { db, fetcher, appUrl, isProd };
ts

This function allows us to just pass `appUrl` and the app will automatically pass the correct URL based on if we’re in development or production. With this addition to our `config.ts` file finished, we’re ready to create our new function for creating checkout sessions. To add this function, create a new file at `./utils/stripe/create-checkout-session.ts` and add the below code.

./utils/stripe/create-checkout-session.ts
1import Stripe from "stripe";
2import { IPlan, IUser } from "@/types";
3import { appUrl } from "@/config";
4
5interface IProps {
6 planId: IPlan["PLAN_ID"];
7 stripeCustomerId: string;
8 user: IUser;
9}
10
11export default async function createCheckoutSession({
12 planId,
13 stripeCustomerId,
14 user,
15}: IProps) {
16 const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
17 apiVersion: "2022-11-15",
18 });
19
20 const checkoutSession = await stripe.checkout.sessions.create({
21 mode: "subscription",
22 allow_promotion_codes: true,
23 customer: stripeCustomerId,
24 customer_update: {
25 address: "auto",
26 },
27 line_items: [
28 {
29 price: planId,
30 quantity: 1,
31 },
32 ],
33 success_url: appUrl,
34 cancel_url: appUrl,
35 subscription_data: {
36 metadata: {
37 payingUserId: user?.pk,
38 },
39 },
40 });
41
42 return checkoutSession.url;
43}
ts

In this function, we first fetch the current user using our `getCurrentUser` function from earlier, we then check if the user has an existing customer ID from Stripe in their data and if not create a new one using the `createStripeUserId` function we wrote a moment ago.

After that, we use the Stripe package to create a new checkout session that connects to the user’s customer ID as well as the current `planId` that is passed in as an argument. These two pieces of information are crucial for telling Stripe who is subscribing and what they’re subscribing to.

We also pass in the two callback URLs required (`success_url` and `cancel_url`) which we point to the `appUrl` variable we defined in our `config.ts`. This means for local development we’ll be returned to our local development server and for production, we’re returned to our production URL.

Finally, the last thing to take note of is the `payingUserId` data we add to the metadata of the checkout session, this is the user’s ID in our database and will be important in a moment when we process the webhook events as this will allow us to easily lookup the user who the event is related to and perform the necessary updates in our database.

Adding the `PlanTable` Component

With the utility functions required for creating our checkout sessions completed, we can now turn our attention to the UI portion of it. For this, we’re going to create a new component that maps over our paid plans and renders a link to the relevant checkout session URL.

To create this component, create a new file at `./components/PlanTable.tsx` and add in the below code.

./components/PlanTable.tsx
1import { PLANS } from "@/constants";
2import getCurrentUser from "@/utils/db/get-current-user";
3import createCheckoutSession from "@/utils/stripe/create-checkout-session";
4import createStripeUserId from "@/utils/stripe/create-stripe-user-id";
5
6export default async function PlanTable() {
7 const user = await getCurrentUser();
8 let stripeCustomerId = "";
9
10 if (!user) return null;
11
12 if (user?.stripeCustomerId) {
13 stripeCustomerId = user.stripeCustomerId;
14 } else {
15 stripeCustomerId = await createStripeUserId({ user });
16 }
17
18 return (
19 <div className="flex flex-col gap-4">
20 <p className="text-xl font-bold">Plans</p>
21 <div className="grid grid-cols-2 w-full text-center gap-10">
22 {PLANS.map(async (plan) => {
23 if (plan.TIER === "FREE") return null;
24
25 const checkoutUrl = await createCheckoutSession({
26 planId: plan.PLAN_ID,
27 stripeCustomerId,
28 user,
29 });
30
31 return checkoutUrl ? (
32 <a
33 href={checkoutUrl}
34 className="bg-blue-200 border-blue-400 border-2 text-blue-600 px-3 py-2 rounded-md drop-shadow-md font-bold"
35 >
36 Buy {plan?.TIER}
37 </a>
38 ) : (
39 <p>Loading...</p>
40 );
41 })}
42 </div>
43 </div>
44 );
45}
tsx

With our plan table UI now added, the last thing we need to do is add it to our home page so users can interact with it. We’re also going to conditionally render the plan table component so that only users who are on a free plan can see it. This is because users who are already on an existing paid plan will be prompted to upgrade, downgrade, cancel, or perform any other management operations via the Stripe Customer Portal which we’ll configure later on in this post.

To add the plan table component to the home page, go to your `./app/page.tsx` file and add the below lines to it.

./app/page.tsx
1import { UserButton } from "@clerk/nextjs";
2import getCurrentUser from "@/utils/db/get-current-user";
3import Button from "@/components/Button";
4// This line 👇
5import PlanTable from "@/components/PlanTable";
6import getPlan from "@/utils/get-plan";
7
8export default async function Home() {
9 const user = await getCurrentUser();
10
11 if (!user) {
12 return <p>Loading...</p>;
13 }
14
15 const { buttonClicks } = user;
16 const plan = getPlan(user.plan);
17
18 if (!plan) {
19 return <p>No User Plan Found</p>;
20 }
21
22 return (
23 <div className="flex flex-col gap-6">
24 <div className="flex flex-row py-4 border-b justify-between items-center">
25 <UserButton afterSignOutUrl="/" />
26 <p className="text-xl">
27 Your Current Plan: <span className="font-bold">{user.plan}</span>
28 </p>
29 </div>
30 <Button plan={plan} current={buttonClicks} />
31 {plan.TIER === "FREE" ? <PlanTable /> : null} {/* This line */}
32 </div>
33 );
34}
tsx

And, with that, we’ve finished everything we need to do for users to be able to select a plan and navigate through Stripe to subscribe to it.

However, if you try to do it yourself at the moment, you’ll notice nothing happens when you subscribe to the plan (remember to use Stripe’s test cards when on test mode) and are redirected back to the application. This is because we’re not listening to or processing any of the webhooks so although you’re subscribed in Stripe, the data was never passed through to our app and our database was never updated, meaning as far as our app is concerned you’re still on the free plan.

NOTE: if you did perform this test, make sure to visit your Stripe account’s “customers” page and cancel the subscription you just created so you don’t have multiple subscriptions later on when we test it.

Handling Stripe’s Webhook Events

As mentioned above, the final piece we need to add to our application to allow users to subscribe to a plan on Stripe and have access to its limits and functionality in our app is the processing of webhooks from Stripe.

To add this we need to add a new utility function to allow us to fetch users from our database by their ID and then create a new API route that will receive the webhooks from Stripe and then process them.

To get started, let’s create that new utility function by creating a new file at `./utils/db/get-user-by-id.ts` and adding the below code to it.

./utils/db/get-user-by-id.ts
1import { GetCommand } from "@aws-sdk/lib-dynamodb";
2import { db } from "@/config";
3import { IGetCommandOutput, IUser } from "@/types";
4
5interface IProps {
6 id: string;
7}
8
9export default async function getUserById({ id }: IProps) {
10 const { Item: user } = (await db.send(
11 new GetCommand({
12 TableName: process.env.DB_TABLE_NAME,
13 Key: {
14 pk: id,
15 sk: id,
16 },
17 })
18 )) as IGetCommandOutput<IUser>;
19
20 return user;
21}
ts

After adding this utility function to fetch a user from our database by their ID, we’re ready to work on the new API route to process our webhooks. To create this, create a new file at `./app/api/webhooks/stripe/route.ts` and add the below code to it.

./app/api/webhooks/stripe/route.ts
1import { NextResponse } from "next/server";
2import Stripe from "stripe";
3import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
4import { IPlan } from "@/types";
5import getUserById from "@/utils/db/get-user-by-id";
6import { db } from "@/config";
7
8export async function POST(request: Request) {
9 try {
10 const sig = request?.headers?.get("stripe-signature");
11 const requestBody = await request.text();
12
13 if (!sig) {
14 return NextResponse.json({ error: "No signature" }, { status: 400 });
15 }
16
17 const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
18 apiVersion: "2022-11-15",
19 });
20 const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
21
22 let event;
23
24 try {
25 // Use the Stripe SDK and request info to verify this Webhook request actually came from Stripe
26 event = stripe.webhooks.constructEvent(requestBody, sig, endpointSecret);
27 } catch (e) {
28 const { message } = e as { message: string };
29 // eslint-disable-next-line no-console
30 console.log(`⚠️ Webhook signature verification failed.`, message);
31 return NextResponse.json(
32 { error: `Webhook signature verification failed.` },
33 { status: 400 }
34 );
35 }
36
37 switch (event.type) {
38 // Handle a subscription being created or updated
39 case "customer.subscription.updated": {
40 const subscription = event.data.object as Stripe.Subscription;
41 const productId = subscription.items.data[0].price.product.toString();
42
43 const userNewTier = (await stripe.products.retrieve(productId)).metadata
44 .tier as IPlan["TIER"];
45
46 const user = await getUserById({
47 id: subscription.metadata.payingUserId,
48 });
49
50 if (!user) {
51 return NextResponse.json({ error: "No user found" }, { status: 400 });
52 }
53
54 // If the user is cancelling their subscription, return and handle it in the .deleted event
55 if (subscription.status === "canceled") {
56 break;
57 }
58
59 await db.send(
60 new UpdateCommand({
61 TableName: process.env.DB_TABLE_NAME,
62 Key: {
63 pk: user?.pk,
64 sk: user?.sk,
65 },
66 ExpressionAttributeNames: {
67 "#s": "subscriptionStatus",
68 "#p": "plan",
69 },
70 UpdateExpression: "set #s = :a, #p = :p",
71 ExpressionAttributeValues: {
72 ":a": "ACTIVE",
73 ":p": userNewTier,
74 },
75 ReturnValues: "ALL_NEW",
76 })
77 );
78
79 break;
80 }
81
82 // Handle a subscription being cancelled
83 case "customer.subscription.deleted": {
84 const subscription = event.data.object as Stripe.Subscription;
85
86 const user = await getUserById({
87 id: subscription.metadata.payingUserId,
88 });
89
90 if (!user) {
91 return NextResponse.json({ error: "No user found" }, { status: 400 });
92 }
93
94 await db.send(
95 new UpdateCommand({
96 TableName: process.env.DB_TABLE_NAME,
97 Key: {
98 pk: user?.pk,
99 sk: user?.sk,
100 },
101 ExpressionAttributeNames: {
102 "#s": "subscriptionStatus",
103 "#p": "plan",
104 },
105 UpdateExpression: "set #s = :a, #p = :p",
106 ExpressionAttributeValues: {
107 ":a": "CANCELLED",
108 ":p": "FREE",
109 },
110 ReturnValues: "ALL_NEW",
111 })
112 );
113 break;
114 }
115 default: {
116 break;
117 }
118 }
119
120 return NextResponse.json(
121 {
122 received: true,
123 },
124 { status: 200 }
125 );
126 } catch (e) {
127 // eslint-disable-next-line no-console
128 console.log("Stripe webhook error", e);
129 return NextResponse.json(
130 {
131 received: false,
132 },
133 { status: 500 }
134 );
135 }
136}
ts

This is a fairly long file with a fair bit going on so let’s break it down into sections and take a look at what each section does to get a better understanding of it. First of all, we define a new function for `POST` requests, this is because Stripe will emit all webhooks as POST requests to the URL we give them.

Then at the top of the function, we define the code below which will handle getting Stripe’s signature from the request headers and getting the raw request body as well as creating a new Stripe client and getting our webhook secret which we defined earlier when setting up the Stripe CLI.

./app/api/webhooks/stripe/route.ts
1const sig = request?.headers?.get("stripe-signature");
2const requestBody = await request.text();
3
4if (!sig) {
5 return NextResponse.json({ error: "No signature" }, { status: 400 });
6}
7
8const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
9 apiVersion: "2022-11-15",
10});
11const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
ts

We then consume all of the above information to verify the webhook request actually came from Stripe and isn’t a malicious actor trying to impersonate Stripe. We do this with the below code, you can read more about webhook request validation on their docs here.

./app/api/webhooks/stripe/route.ts
1let event;
2
3try {
4 // Use the Stripe SDK and request info to verify this Webhook request actually came from Stripe
5 event = stripe.webhooks.constructEvent(requestBody, sig, endpointSecret);
6} catch (e) {
7 const { message } = e as { message: string };
8 // eslint-disable-next-line no-console
9 console.log(`⚠️ Webhook signature verification failed.`, message);
10 return NextResponse.json(
11 { error: `Webhook signature verification failed.` },
12 { status: 400 }
13 );
14}
ts

Once we have verified the webhook event has indeed come up from Stripe, we move on to processing it which we do with a switch statement. In the switch statement, we define a case for every webhook event type we’d like to process and handle in our app.

In the case of our application, we want to handle two event types, these are `customer.subscription.updated` and `customer.subscription.deleted`. These two events allow us to handle users creating and updating a subscription (`.updated`) as well as cancelling their subscriptions (`.deleted`). Here is the code we use to define the switch statement.

./app/api/webhooks/stripe/route.ts
1switch (event.type) {
2 // Handle a subscription being created or updated
3 case "customer.subscription.updated": {
4 // Logic for creating or updating subscriptions
5
6 break;
7 }
8
9 // Handle a subscription being cancelled
10 case "customer.subscription.deleted": {
11 // Logic for cancelling subscriptions
12
13 break;
14 }
15 default: {
16 break;
17 }
18}
ts

Now, we add in the part where the “magic” happens so to say, for the `.updated` event, we use the below code which retrieves the data on the subscription from the event and then in turn retrieves the information on the product subscribed to as well as the user who subscribed by using the `getUserById` function from earlier. We then send an `UpdateCommand` to our database to change their `subscriptionStatus` to `ACTIVE` and their `plan` to the plan they subscribed to on Stripe.

NOTE: We perform a check before updating the database to see if the subscription status is “canceled” or not. This is only required if you’re configuring subscriptions to immediately cancel as we are in this example project.

If you’re configuring subscriptions to cancel at the end of the billing period this isn’t required because the `.updated` and `.deleted` events will be triggered at different times and so don’t have the possibility of overlapping like they do with immediate cancellation. This is because Stripe doesn’t guarantee the order of webhook event delivery.

./app/api/webhooks/stripe/route.ts
1// Handle a subscription being created or updated
2case 'customer.subscription.updated': {
3 const subscription = event.data.object as Stripe.Subscription;
4 const productId = subscription.items.data[0].price.product.toString();
5
6 const userNewTier = (await stripe.products.retrieve(productId)).metadata
7 .tier as IPlan['TIER'];
8
9 const user = await getUserById({
10 id: subscription.metadata.payingUserId,
11 });
12
13 if (!user) {
14 return NextResponse.json({ error: 'No user found' }, { status: 400 });
15 }
16
17 // If the user is cancelling their subscription, return and handle it in the .deleted event
18 if (subscription.status === 'canceled') {
19 break;
20 }
21
22 await db.send(
23 new UpdateCommand({
24 TableName: process.env.DB_TABLE_NAME,
25 Key: {
26 pk: user?.pk,
27 sk: user?.sk,
28 },
29 ExpressionAttributeNames: {
30 '#s': 'subscriptionStatus',
31 '#p': 'plan',
32 },
33 UpdateExpression: 'set #s = :a, #p = :p',
34 ExpressionAttributeValues: {
35 ':a': 'ACTIVE',
36 ':p': userNewTier,
37 },
38 ReturnValues: 'ALL_NEW',
39 })
40 );
41
42 break;
43}
ts

With our `.updated` event now sorted, all we need to do is handle the `.deleted` event which is a lot simpler because we know if the user is cancelling their plan, they’re going to be downgraded to the free plan. This means all we need to do is get the user’s data from the database using the `getUserById` function and then perform an update to set their `subscriptionStatus` to `CANCELLED` and then set their `plan` to `FREE`.

./app/api/webhooks/stripe/route.ts
1// Handle a subscription being cancelled
2case 'customer.subscription.deleted': {
3 const subscription = event.data.object as Stripe.Subscription;
4
5 const user = await getUserById({
6 id: subscription.metadata.payingUserId,
7 });
8
9 if (!user) {
10 return NextResponse.json({ error: 'No user found' }, { status: 400 });
11 }
12
13 await db.send(
14 new UpdateCommand({
15 TableName: process.env.DB_TABLE_NAME,
16 Key: {
17 pk: user?.pk,
18 sk: user?.sk,
19 },
20 ExpressionAttributeNames: {
21 '#s': 'subscriptionStatus',
22 '#p': 'plan',
23 },
24 UpdateExpression: 'set #s = :a, #p = :p',
25 ExpressionAttributeValues: {
26 ':a': 'CANCELLED',
27 ':p': 'FREE',
28 },
29 ReturnValues: 'ALL_NEW',
30 })
31 );
32 break;
33}
ts

Then finally, with all of the logic added to our webhook API route, the last thing we need to do is close out the function with a return statement to send a `200` response to Stripe once we have successfully processed it. Or, if something has gone wrong a `500` error.

./app/api/webhooks/stripe/route.ts
1return NextResponse.json(
2 {
3 received: true,
4 },
5 { status: 200 }
6 );
7} catch (e) {
8 // eslint-disable-next-line no-console
9 console.log('Stripe webhook error', e);
10 return NextResponse.json(
11 {
12 received: false,
13 },
14 { status: 500 }
15 );
16}
ts

And, that is everything we do in our webhook API route to process events from Stripe and allow users to actually subscribe to plans as well as handle the cancellation of them which we’re going to add the functionality for in the next section when we configure the Stripe Customer Portal.

A Note on the Stripe CLI

When developing and testing the webhooks locally make sure you have the Stripe CLI running in a terminal window with the command `stripe listen --forward-to localhost:3000/api/webhooks/stripe`. This command will listen to all events on your Stripe account and forward them to the API route we just defined for processing them.

If you forget to run this command and perform an action like subscribing to a plan, the data on Stripe will be updated but the data in our database won’t be. This means you’ll need to do what we mentioned earlier and go into Stripe and manually cancel the subscription before retrying with the CLI command running.

Configuring Stripe’s Customer Portal

We’re now approaching the end of the development process, the last thing we need to configure is Stripe’s customer portal. We need to configure this so users can self-manage their billing information, invoices, and subscriptions which will allow them to upgrade, downgrade and cancel as they wish.

Stripe Configuration

To get started with configuring the customer portal, head to the Stripe dashboard and then search for “Customer Portal” and select the option for “Settings > Billing > Customer Portal”.

Once on the configuration page for the customer portal, we need to update a few settings. The first one is the ability for users to switch between plans, to do this, click on “Subscriptions” and then enable “Customers can switch plans” and then add the products we created earlier to the list of products they can choose from.

The second change we’re going to make is an optional one to make testing our app a bit easier. This change makes it so the plans cancel immediately when the user cancels their subscription via the portal, which you can enable under the “Cancellations” menu.

This change makes testing our app easier because usually, Stripe will cancel the subscription at the end of the billing period which is the behaviour you’d want in production. But, while testing this is an inconvenience because it means the `deleted` webhook won’t be sent until the end of the billing period. To get around this, you can manually go into the Stripe dashboard and immediately cancel a user’s subscription to send the `deleted` webhook but to make testing easier, I recommend just setting the billing portal to cancel the subscription immediately.

Implementing the Customer Portal

To implement the customer portal into our application, we need to create a new utility function as well as a component to link the user to the customer portal’s URL. This is a very similar logic to what we did for the checkout links earlier for users to subscribe to a plan.

Let’s get started by creating the utility function, for this, create a new file at `./utils/stripe/create-customer-portal-session.ts` and then add the below code to it.

./utils/stripe/create-customer-portal-session.ts
1import Stripe from "stripe";
2import getCurrentUser from "../db/get-current-user";
3import { appUrl } from "@/config";
4
5export default async function createCustomerPortalSession() {
6 const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
7 apiVersion: "2022-11-15",
8 });
9
10 const user = await getCurrentUser();
11
12 if (!user) {
13 throw new Error("User not found");
14 }
15
16 const portalSession = await stripe.billingPortal.sessions.create({
17 customer: user.stripeCustomerId || "",
18 return_url: appUrl,
19 });
20
21 return portalSession.url;
22}
ts

In this function, we get the current user from our `getCurrentUser` function and then generate a new portal session for that user using their stripe customer ID. We also pass in our `appUrl` variable we defined earlier so our `returnUrl` is correct to the environment we’re running our application in.

Now, with our utility function written, we’re ready to move on to creating our new component to link to the customer portal itself. To create this, create a new file at `./components/CustomerPortal.tsx` and add the below code to it.

./components/CustomerPortal.tsx
1import getCurrentUser from "@/utils/db/get-current-user";
2import createCustomerPortalSession from "@/utils/stripe/create-customer-portal-session";
3
4export default async function CustomerPortal() {
5 const user = await getCurrentUser();
6
7 if (user?.plan === "FREE") {
8 return null;
9 }
10
11 const customerPortalUrl = await createCustomerPortalSession();
12
13 return customerPortalUrl ? (
14 <a
15 href={customerPortalUrl}
16 className="border-blue-400 text-blue-600 hover:bg-blue-200 duration-150 ease-in-out border-2 px-3 py-2 rounded-md font-bold w-max"
17 >
18 Manage Your Subscription
19 </a>
20 ) : (
21 <p>Loading...</p>
22 );
23}
tsx

In this component, we get the current user and then check if they’re on a paid plan, if they are, we create a new customer portal session by using the utility function we just defined before then rendering an `a` tag pointing to the portal URL.

Finally, to complete the billing portal configuration for our application, we just need to add it to the home page of our application. To do this, update your `./app/page.tsx` file to include the below lines.

./app/page.tsx
1import { UserButton } from "@clerk/nextjs";
2import getCurrentUser from "@/utils/db/get-current-user";
3import Button from "@/components/Button";
4import PlanTable from "@/components/PlanTable";
5import getPlan from "@/utils/get-plan";
6// This line 👇
7import CustomerPortal from "@/components/CustomerPortal";
8
9export default async function Home() {
10 const user = await getCurrentUser();
11
12 if (!user) {
13 return <p>Loading...</p>;
14 }
15
16 const { buttonClicks } = user;
17 const plan = getPlan(user.plan);
18
19 if (!plan) {
20 return <p>No User Plan Found</p>;
21 }
22
23 return (
24 <div className="flex flex-col gap-6">
25 <div className="flex flex-row py-4 border-b justify-between items-center">
26 <UserButton afterSignOutUrl="/" />
27 <p className="text-xl">
28 Your Current Plan: <span className="font-bold">{user.plan}</span>
29 </p>
30 </div>
31 <Button plan={plan} current={buttonClicks} />
32 {plan.TIER === "FREE" ? <PlanTable /> : null}
33 <CustomerPortal /> {/* This line */}
34 </div>
35 );
36}
tsx

And, with that, we’ve finished building Clicky! At this point, you should now have a fully working application that you can use and test by running your local web server with `npm run dev`.

In the next (and final) section, we’re going to take a look at deploying your application to Vercel and the steps you’d need to complete if you wanted to take payment and make it a live working SaaS product!

Deploying our project

Finally, we’re at the fun part of the project, where we take all of our hard work and deploy it for the world to see and use!

Stripe

The first thing we’ll want to do is configure our webhooks endpoint on Stripe. Unlike in local development where we could use the Stripe CLI to consume the webhook events from Stripe, when we deploy our app we can’t do this.

To resolve this we need to add an endpoint to our webhook settings on Stripe and then, after we do this, Stripe will send all of the events from our account that match our configuration to that URL.

To configure this go to your Stripe dashboard and head to the “Developers” tab. From there, go to the “Webhooks” tab and then click on “Add an endpoint”. It’ll require a URL to be entered so if you know the URL you’ll be deploying your application to, you can use that.

If not, or you want to use a temporary URL from Vercel like I will, you can enter a placeholder for now such as `https://example.com/api/webhooks/stripe`. If you were using your own domain, you could switch the `https://example.com` with your chosen domain name.

You can then leave the rest of the settings as the default ones apart from the events you want to listen to. For this option, you’ll want to select `customer.subscription.deleted` and `customer.subscription.updated` as they’re what we configured in our API route earlier. You can then press “Add endpoint” and complete the creation of it.

After your webhook has been created, you need to copy the “Signing secret” of the webhook which you can get by clicking on “Reveal” on the webhook’s settings page we just created.

Vercel

After we’ve completed the configuration of our Stripe settings, we’re ready to deploy our project to Vercel. For this, push your code to the git provider of your choice, for me this will be GitHub. Then on your Vercel account, add a new project and select the new repository you just created.

You’ll then want to add in all of the environment variables that we’ve configured for AWS, Stripe and Clerk before finally deploying your app to Vercel. Make sure to use your webhook signing secret and not the one we used locally.

Using a Temporary Domain

At this point if you’re using your own domain name and have configured that in the `config.ts` file and on Stripe, you should be good to go!

However, if you’re using a temporary Vercel domain, take your domain name from the Vercel dashboard and update the production URL in the `config.ts` file before committing and redeploying the app.

Finally, make sure to update the placeholder domain we used in Stripe when configuring the webhook endpoint a moment ago to be the temporary domain Vercel gave you. At this point, once the app has finished deploying again, you should now be able to use the app!

You can test everything works by signing into the app, clicking the button and taking out a paid plan via Stripe to increase your limit. You can also use the customer portal to upgrade/downgrade or cancel your subscription to make sure the app updates accordingly.

Things to Keep in Mind

To conclude deploying our application, I want to note a few things that I think you should be aware of when it comes to deploying a SaaS product using the technologies we have.

Stripe

If you were deploying your SaaS product for real users to subscribe to you’d want to go through the Stripe verification process to access “live” mode and be able to process actual payments from users.

If you do this, you’ll need to go back through this tutorial and reconfigure everything we did on Stripe but this time in “live” mode, including things such as creating products and prices. You’d then want to take the IDs of these new items and update your Vercel ENVs to use these values. You’ll also want to make sure you update your Stripe keys on Vercel to be the “live” mode ones as well as create a new webhook for your production application to use as well as update the signing secret ENV for this.

Clerk

Because we’re deploying to a temporary domain on Vercel and this is just an example project, we’re unable to use Clerk’s production environment as that requires you to use your own domain name.

However, if this was for a real SaaS with it’s own domain name, you should configure the production environment by going to your Clerk dashboard and then clicking on the “Development” label at the top of the screen and then selecting “Production” and following the prompts presented.

AWS

Finally, although this isn’t a requirement, something I would highly recommend for a production SaaS product is to create a standalone AWS account for your production resources. You can use the same code and process we covered in this tutorial but with a separate account that only hosts your production resources and data.

Make sure to update your ENVs on Vercel if you do this to make sure your deployment is connecting to the right AWS account and resources.

Closing Thoughts

And, that’s it! We’ve reached the end of the tutorial and we’ve looked at everything you need to know about building your own SaaS product using Next.js app router, AWS DynamoDB, Clerk, and Stripe.

In this post, we’ve covered the basics of building a SaaS application but if you want an extra challenge to take this tutorial one step further, here are some ideas of how you could extend the functionality we’ve implemented.

  1. Extend the functionality of Clicky to have multiple features per plan.
  2. Extend our Stripe integration
    1. Handle failed payments with automatic retries as well as auto-cancellation of a subscription if the payment fails X times.
    2. Add an annual billing options for each plan

Finally, if you’d like to see the complete code for this project, you can see it over on my GitHub here. Also, you can checkout the deployed example app here. And, if you have any questions, feel free to contact me and I’d be more than happy to help.

Thank you for reading

Coner



Content

Latest Blog Posts

Below is my latest blog post and a link to all of my posts.

View All Posts

Content

Latest Video

Here is my YouTube Channel and latest video for your enjoyment.

View All Videos
AWS Bedrock Text and Image Generation Tutorial (AWS SDK)

AWS Bedrock Text and Image Generation Tutorial (AWS SDK)

Contact

Join My Newsletter

Subscribe to my weekly newsletter by filling in the form.

Get my latest content every week and 0 spam!