DEV Community

Cover image for How to add User Accounts and Paid Subscriptions to your Next.js Website
Andrew Jones
Andrew Jones

Posted on • Updated on

How to add User Accounts and Paid Subscriptions to your Next.js Website

Recently I spent a weekend adding paid subscriptions to my project, so I'm writing this article to share the process and what I wish I had known before I started!

This tutorial will assume some experience with React and TypeScript. You'll also need a database to use, any one that's compatible with Prisma. I'll be using Postgres. You can follow my last tutorial (up to the REST API section) for a beginner's guide on setting up Postgres locally, and an intro to Prisma.

The Goal

In this tutorial, we'll create a Next.js site, set up a database, add user registration (via GitHub OAuth), and give customers the ability to sign up for a paid subscription using a Stripe Checkout hosted page. Many of the same concepts apply even if you're using a different OAuth provider, a custom payment form, or a different payment provider.

We're going to set up a system like this: when a user registers for an account on your site, we'll also create a customer in Stripe's system for the user, and we'll save the Stripe customer ID in our database with the user's data. Then, when a user wants to add a subscription to their account on our site, we can use that Stripe customer ID to easily mark the user as a paying user in our system, and then allow them access to our services. We'll also discuss next steps to allow users to cancel their subscriptions and more. The flow will look like this:

Architecture Diagram

TLDR

  1. Set up a Next.js Project
  2. Add Prisma and set up a Database
  3. Add Next-Auth and configure account creation
  4. Create a Stripe Account
  5. On account-creation, use a Next-Auth event to create a Stripe Customer and link them
  6. Allow the frontend to request a Stripe Payment Link from the backend, pre-linked to their customer ID
  7. Use Stripe Webhooks to activate the customer's subscription in our database when they complete a checkout
  8. Test the flow

Set Up a Project

Follow the excellent official guide here to set up a Next.js project. I'm using TypeScript, which works especially well with Prisma.

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

When that's finished, make sure you have typescript and React types installed using:

npm install --save-dev typescript @types/react
Enter fullscreen mode Exit fullscreen mode

To do some cleanup, you can delete all of the content inside the <main>...</main> section of index.tsx.

Adding Prisma and Database Setup

One mistake I made was that I implemented my entire authentication system and database schema without accounting for payment-related fields. We'll fix that by creating our initial schema with both next-auth and stripe in mind.

Next-Auth and Stripe

Next-Auth is a great way of easily adding user registration and authentication to your Next.js projects. Its docs provide you everything you need to get started with a huge variety of authentication providers and databases. You can read more about it at https://next-auth.js.org/.

Next Auth logo

Stripe is one of the most popular payment systems existing today. It essentially allows you to build payment forms into your apps, websites, and servers, and it handles all of the complex logic behind communicating with credit card companies and banks to actually get you your payment. It supports a ton of use cases, including paid subscriptions, which is what we'll use it for. Read more about it at https://stripe.com/.

Stripe logo

Setting up the Prisma Schema

First, we'll set up Prisma. If you get stuck on this part, check Prisma's documentation. Start by creating a new folder in your project folder called prisma, and then a file called schema.prisma inside the folder.

Next we need to determine what else to put in this schema file. The schema file determines the structure of the database and TypeScript types that Prisma will generate.

We need to connect Next-Auth to Prisma, so that it can actually save user accounts after they're created. To do that, we'll use the official Next-Auth Prisma Adapter. We'll install it later, but for now, copy the text from the schema file shown here and paste it into your schema file. These are the fields which the Next-Auth Prisma Adapter requires for its features to work. If you're not using Postgres, you'll need to change the database part at the top of the file; check Prisma's documentation for more info on how to do that. You should also delete the shadowDatabaseURL and previewFeatures lines, unless you're using an old version of Prisma, which you shouldn't be doing :)

We'll also add a field for the Stripe customer ID. This will give us a method to link newly created subscriptions with existing customers in our database. And lastly, we'll add a boolean field isActive to determine if a user should have access to our service. Add these lines inside the User model in the schema:

model User {
   ...
   stripeCustomerId        String?
   isActive                Boolean            @default(false)
}
Enter fullscreen mode Exit fullscreen mode

Lastly, depending on which Authentication provider you want to use, you may need to add some additional fields. Authentication provider refers to services we can use for our users to sign-in with, such as "Sign in with Google" or "Sign in with Facebook." Next-Auth has a long list of built-in providers. For this tutorial we'll use GitHub.

The GitHub provider requires one additional field, so add this to the Account model:

model Account {
   ...
   refresh_token_expires_in       Int?
}
Enter fullscreen mode Exit fullscreen mode

Set Up your Environment Variables

Now that the schema is complete, we need to actually link Prisma to our database. First, add a line which says .env to your .gitignore file. This is EXTREMELY important to make sure you don't actually commit your environment variables and accidentally push them to GitHub later.

Next, create a file called .env in your project folder (not in the Prisma folder). The content to add will depend on your database. For a local Postgres database, you should write the following in your .env.local: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA. To create a new database in psql, run create database subscriptionstutorial or swap out "subscriptionstutorial" for another name specific to your project.

Create the database and Prisma client!

Run npx prisma migrate dev --name init to set up your database. If you hit any syntax issues with the schema, re-check the schema on the Prisma docs and the fields above. If you hit issues with the database connection, check your database through your CLI (for example, using psql for Postgres) to make sure its online and you have the right Database URL.

What just happened?!

  1. Prisma checked your .env for the database URL.
  2. Prisma created and ran SQL commands for you, automatically, to create database tables with columns in a structure that match your schema.
  3. Prisma created the Prisma Client, which contains fully-typed methods for interacting with your database, with the types corresponding to your schema.

Create a dev-safe Prisma Client instance

If we want to actually use Prisma Client to interact with the database, we need to create a client with new PrismaClient(). However, in development mode, hot-reloading can cause the Prisma Client to regenerate too many times.

To fix that, we can use a shared, global Prisma Client in development. Create a file in the prisma folder called shared-client.ts and add this content:

import { PrismaClient } from '@prisma/client';
import type { PrismaClient as PrismaClientType } from '@prisma/client';
let prisma: PrismaClientType;

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient();
  }

  prisma = global.prisma;
}

export { prisma }
Enter fullscreen mode Exit fullscreen mode

Set Up Next-Auth

Next, we'll add user account creation to our site. Since we're using Prisma for connecting Next-Auth to the database, and GitHub as our OAuth provider, we'll base the configuration off of the docs pages for the Prisma adapter and the GitHub provider.

First, do npm install next-auth @prisma/client @next-auth/prisma-adapter. The GitHub provider is built-in to next-auth, it doesn't require a separate package.

Delete the file /pages/api/hello.js and add a new file pages/api/auth/[...nextauth].ts.

In the file, add this content:

import NextAuth from "next-auth";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import GithubProvider from "next-auth/providers/github";
import { prisma } from "../../../prisma/shared-client";

export default NextAuth({
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
    }),
  ],
  adapter: PrismaAdapter(prisma),
}
Enter fullscreen mode Exit fullscreen mode

To create the GitHub Client ID and Client Secret, go to https://github.com/settings/profile, Developer Settings on the left-hand nav-bar, OAuth Apps, New OAuth App. Fill in a name and your localhost with port for the homepage URL. Copy the homepage URL and add /api/auth/callback/github. This will allow the /api/auth/[...nextauth].ts file to catch this callback URL and use it to create a user in the database. The form should look something like this:

GitHub App creation form

After you create the OAuth app, add the Client ID, a Client Secret, and your local URL into your .env like this:

GITHUB_CLIENT_ID="fill-in-value-from-github-xyz123"
GITHUB_CLIENT_SECRET="fill-in-value-from-github-abc123"
NEXTAUTH_URL="http://localhost:3000"
Enter fullscreen mode Exit fullscreen mode

As an additional convenience, we'll extend the session object to contain the user ID. Add a callbacks field with a session callback function which returns an extended session like this:

export default NextAuth({
  providers: ...,
  adapter: ...,
  callbacks: {
    async session({ session, user }) {
      session.user.id = user.id;
      return session;
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

TypeScript users will also need to extend the session.user type to add this field to it. In the project root, create a file called types.d.ts and add this content there:

import type { DefaultUser } from 'next-auth';

declare module 'next-auth' {
  interface Session {
    user?: DefaultUser & {
      id: string;
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the basic Next-Auth setup - technically, we could now add the frontend sign-up form. But instead, before we get there, we should plan ahead for how we'll connect the user accounts with Stripe.

When we create a user, we'll also create a Stripe customer. This will allow us to easily link customers in our DB to the subscriptions and their payments when customers pay after creating an account.

Set up Stripe

Set up a Stripe Account

On Stripe's website, create a new account and a business. You don't need to enter all of your business information, especially if you don't have it yet! Just enter the minimum info to get started.

Add Stripe to the Project

The part of this tutorial that I spent the most time figuring out was how to connect Stripe customers to our site's accounts. This setup will allow for that.

Add Stripe's node.js SDK to the project with npm install stripe.

Go to https://dashboard.stripe.com/test/apikeys, which should look like this:

Stripe account API keys page

On the "Secret Key" row, hit Reveal test key and copy that key into your .env like this:

STRIPE_SECRET_KEY="sk_test_abc123"
Enter fullscreen mode Exit fullscreen mode

You don't need the Publishable key at the moment!

Create a Stripe Customer for Newly Registered Accounts

To accomplish this, we'll use the Next-Auth event system. Events allow Next-Auth to do some custom action after certain user actions like creating a new account or signing in, without blocking the auth flow. Read more about the event system here.

In the [...nextauth].ts file, add the events field as an object with a createUser function like this:

export default NextAuth({
  providers: ...
  adapter: ...,
  callbacks: ...,
  events: {
    createUser: async ({ user }) => {

    });
  }
})
Enter fullscreen mode Exit fullscreen mode

Next-Auth will call this function after a new user account is registered.

Inside of the function, we'll use the Stripe SDK to create a customer, and then add the Stripe customer ID to our saved record for the customer account:

createUser: async ({ user }) => {
      // Create stripe API client using the secret key env variable
      const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
        apiVersion: "2020-08-27",
      });

      // Create a stripe customer for the user with their email address
      await stripe.customers
        .create({
          email: user.email!,
        })
        .then(async (customer) => {
          // Use the Prisma Client to update the user in the database with their new Stripe customer ID
          return prisma.user.update({
            where: { id: user.id },
            data: {
              stripeCustomerId: customer.id,
            },
          });
        });
    },
Enter fullscreen mode Exit fullscreen mode

Woohoo! If you're with me so far, we've finished the hardest part!

Front-end and Payment form

We're finally ready to build the frontend!

Sign Up Form

Rename pages/index.js to pages/index.tsx and then open that file.

Import the frontend parts of next-auth by adding this line to the top of the file:

import { signIn, signOut, useSession } from 'next-auth/react'
Enter fullscreen mode Exit fullscreen mode

Next-Auth automatically manages and updates the state of the data returned by useSession, so we can use that hook to track the customer's sign-in status and account.

In the exported Home page function, add:

const {data, status} = useSession()
Enter fullscreen mode Exit fullscreen mode

In the tag, which should be empty, add the following content to decide what to render based on the user's status:

<main>
    {status === 'loading' && <p>Loading...</p>}
    {status === 'unauthenticated' && <button onClick={() => signIn()}>Sign In</button>}
    {status === 'authenticated' && <button onClick={() => signOut()}>Sign Out</button>}
    {data && <p>{JSON.stringify(data)}</p>}
</main>
Enter fullscreen mode Exit fullscreen mode

Note: the signIn() function handles both registering for a new account and signing in to an existing account.

We also need to add a global data provider for the useSession hook to connect to. Set this up in _app.js like this:

import "../styles/globals.css";
import { SessionProvider } from "next-auth/react";

function MyApp({ Component, pageProps }) {
  return (
    <SessionProvider session={pageProps.session}>
      <Component {...pageProps} />
    </SessionProvider>
  );
}

export default MyApp;
Enter fullscreen mode Exit fullscreen mode

Test Account Creation

Run the site with npm run dev.

You should see a button which says Sign In.

Click there, then Sign in With GitHub, and follow the authorization flow.

If everything worked, you should be returned to your frontend with the button now reading "Sign Out" and your account data below. Also, if you go to your Stripe Dashboard and check the Customers tab, you should see a row with a new customer that has your GitHub account's email!

Data shown after a successful sign-in

Customers page in Stripe with new row

Use Stripe To Add Payments

The Approach

Most of our Stripe integration will be powered by a Stripe Checkout page, and Webhooks.

A Stripe Checkout page is a single page that Stripe automatically generates for us, with a payment form that full form functionality, accessibility, and more features. It's a great way to quickly add flexible payments to your site. The one challenge is that it's hosted on Stripe's site, not part of our codebase, so we need some way to send data from Stripe back to our system after a customer purchases a subscription on the Stripe Checkout Page.

To solve that problem, we'll use webhooks. A webhook is nothing super new - it's an API endpoint in OUR system that an EXTERNAL system can use to communicate with our system. In our case, the webhook API endpoint will allow Stripe to "hook" into our system by sending some data for our server to process and handle.

In summary: after creating an account, we'll redirect new users to the Stripe Checkout page for them to pay. Then Stripe will call our webhook to send some data back to our system, and we'll update the database based on that data.

Get the Stripe CLI

To watch all of the events that Stripe sends over webhooks in real time, we'll use the Stripe CLI so that Stripe can post its events to our local devices.

Follow the instructions here to install the Stripe CLI.

Next, follow Step 3 here to connect Stripe to your local server. Use the URL http://localhost:YOUR_PORT/api/webhooks/stripe which we will create in the next step. For example, mine is http://localhost:3000/api/webhooks/stripe.

When you get the CLI installed and started, copy the "webhook signing secret" which the CLI will print into a temporary note.

Create the Webhook

Create a new file pages/api/webhooks/stripe.ts.

Since we are using a public-facing webhook, we have a small problem: imagine if a hacker found this Stripe webhook and sent some fake data about a payment - they could trick our system into giving them access to the benefits of a paid subscription.

Therefore, before we can trust data from a Stripe webhook, we need to check if the request actually came from Stripe. After we verify the call is from Stripe, we can read the data and take some action.

This post by Max Karlsson explains the Stripe verification process in Next.js API Routes really well, so I won't go through it in detail. I'll just include my final webhook code here, which verifies the webhook data and then uses Prisma to update the user to isActive=true when they've paid:

import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import Stripe from 'stripe';
import { prisma } from '../../../prisma/shared-client';

const endpointSecret = // YOUR ENDPOINT SECRET copied from the Stripe CLI start-up earlier, should look like 'whsec_xyz123...'

export const config = {
  api: {
    bodyParser: false, // don't parse body of incoming requests because we need it raw to verify signature
  },
};

export default async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
  try {
    const requestBuffer = await buffer(req);
    const sig = req.headers['stripe-signature'] as string;
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
      apiVersion: '2020-08-27',
    });

    let event;

    try {
      // Use the Stripe SDK and request info to verify this Webhook request actually came from Stripe
      event = stripe.webhooks.constructEvent(
        requestBuffer.toString(), // Stringify the request for the Stripe library
        sig,
        endpointSecret
      );
    } catch (err: any) {
      console.log(`⚠️  Webhook signature verification failed.`, err.message);
      return res.status(400).send(`Webhook signature verification failed.`);
    }

    // Handle the event
    switch (event.type) {
      // Handle successful subscription creation
            case 'customer.subscription.created': {
        const subscription = event.data.object as Stripe.Subscription;
        await prisma.user.update({
          // Find the customer in our database with the Stripe customer ID linked to this purchase
          where: {
            stripeCustomerId: subscription.customer as string
          },
          // Update that customer so their status is now active
          data: {
            isActive: true
          }
        })
        break;
      }
      // ... handle other event types
      default:
        console.log(`Unhandled event type ${event.type}`);
    }

    // Return a 200 response to acknowledge receipt of the event
    res.status(200).json({ received: true });
  } catch (err) {
    // Return a 500 error
    console.log(err);
    res.status(500).end();
  }
};

Enter fullscreen mode Exit fullscreen mode

With me still? Just a few more steps 😃

Create your Subscription Plan in Stripe

In order for our customers to subscribe to a subscription, we need to actually create the payment plan in Stripe. Go to the Products tab in Stripe. Click "Add Product" in the top right and fill out the form with a name and any other info you want to add. For a subscription model, in the Price Information section, make sure to choose "Pricing Model: Standard", select "Recurring", choose your Billing period (how often the customer is charged, renewing the subscription) and enter a price. It should look something like this:

Create Stripe product form

When you're done, press "Save Product". You're taken back to the product tab, where you should click on the row of the product you just added. Scroll to the "Pricing" section on the Product page, and copy the Price's "API ID" into a note file. It should look something like price_a1B23DefGh141.

Add an Endpoint to Create Payment Pages for Users

Stripe will host the payments page, but we want to dynamically generate that page for each user, so that we can automatically link it to their pre-existing Stripe Customer ID, which is linked to their User Account in our database. (phew, that's a mouth-full).

Remember much earlier, when we added the User ID to the Session? That will become useful now so that we can link the Checkout Page to the user in the current session.

Add a file pages/api/stripe/create-checkout-session.ts. Add this content to the file, which includes some error handling:

import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/react';
import Stripe from 'stripe';

export default async (req: NextApiRequest, res: NextApiResponse) => {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
    apiVersion: '2020-08-27',
  });

  // This object will contain the user's data if the user is signed in
  const session = await getSession({ req });

  // Error handling
  if (!session?.user) {
    return res.status(401).json({
      error: {
        code: 'no-access',
        message: 'You are not signed in.',
      },
    });
  }

  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'subscription',
 /* This is where the magic happens - this line will automatically link this Checkout page to the existing customer we created when the user signed-up, so that when the webhook is called our database can automatically be updated correctly.*/
    customer: session.user.stripeCustomerId,
    line_items: [
      {
        price: // THE PRICE ID YOU CREATED EARLIER,
        quantity: 1,
      },
    ],
    // {CHECKOUT_SESSION_ID} is a string literal which the Stripe SDK will replace; do not manually change it or replace it with a variable!
    success_url: `http://localhost:3000/?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: 'http://localhost:3000/?cancelledPayment=true',
    subscription_data: {
      metadata: {
        // This isn't 100% required, but it helps to have so that we can manually check in Stripe for whether a customer has an active subscription later, or if our webhook integration breaks.
        payingUserId: session.user.id,
      },
    },
  });

  if (!checkoutSession.url) {
    return res
      .status(500)
      .json({ cpde: 'stripe-error', error: 'Could not create checkout session' });
  }

  // Return the newly-created checkoutSession URL and let the frontend render it
  return res.status(200).json({ redirectUrl: checkoutSession.url });
};
Enter fullscreen mode Exit fullscreen mode

Why don't we need signature verification here? The data is coming from our frontend, not Stripe. Ok, but do we need to verify the request is actually from our frontend? No, because this endpoint doesn't have any ability to update the customer status in the database. If a 3rd party managed to call this endpoint, all that they would get is a link to a payment page, which doesn't provide them any way around paying for our subscription.

Get a Checkout URL on the Homepage and Send the User There

Back in your frontend code, go back to the homepage in index.tsx. We need to request a checkout URL to redirect users to.

Add this code into your homepage:


  const [isCheckoutLoading, setIsCheckoutLoading] = useState(false);

  const goToCheckout = async () => {
    setIsCheckoutLoading(true);
    const res = await fetch(`/api/stripe/create-checkout-session`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
    });
    const { redirectUrl } = await res.json();
    if (redirectUrl) {
      window.location.assign(redirectUrl);
    } else {
      setIsCheckoutLoading(false);
      console.log("Error creating checkout session");
    }
  };
Enter fullscreen mode Exit fullscreen mode

Now to actually use it, we're going to rewrite what we show to signed-in users.

Find {data && <p>{JSON.stringify(data)}</p>} in your homepage code, and change it to this:

{data && (
          <div>
            <p>{JSON.stringify(data)}</p>
            <p>Add a payment method to start using this service!</p>
            <button
              onClick={() => {
                if (isCheckoutLoading) return;
                else goToCheckout();
              }}
            >
              {isCheckoutLoading ? "Loading..." : "Add Payment Method"}
            </button>
          </div>
        )}
Enter fullscreen mode Exit fullscreen mode

Test it all out!

To check whether or not it works, we'll need isActive to be included in the session. Follow these steps to implement it:

  1. add isActive: boolean; to the user type in types.d.ts
  2. Update the [...nextauth].ts session callback to match the following:
 callbacks: {
    async session({ session, user }) {
      session.user.id = user.id;

      const dbUser = await prisma.user.findFirst({
        where: {
          id: user.id,
        }
      })

      session.user.isActive = dbUser.isActive;

      return session;
    },
  },
Enter fullscreen mode Exit fullscreen mode

Steps to Test the Full Integration:

  1. Check your Stripe CLI to ensure it's still running. If it isn't, re-run it and make sure the signing secret is up-to-date in your webhook file.

  2. with the site running, go to the frontend. Press Sign In and you should see this page:

Plain page with button that reads "sign in with github"

  1. Press the button and you should be taken to GitHub, where you should grant access to the OAuth app.

  2. You should then be redirected to the homepage, where you'll see isActive: false in the user data, because we didn't add a payment method yet.

  3. Press "Add Payment Method" and you should be taken to the Stripe Checkout page!

  4. Confirm that the rate and billing interval is correct on the left side of the page. On the right side, enter 4242424242424242 as the credit card number, one of Stripe's test numbers. Enter any Expiration month as long as it's in the future. Enter any CVC, Zip, and name, and press Subscribe.

  5. After a brief loading period, you should be directed back to your homepage, with one major change: isActive is now true! 🎉🎊

Debugging

If it didn't work, try these debugging tips:

  1. Make sure all of your environment variables are correct.
  2. In the callback.session function, console.log the user argument, the DB user found via Prisma, and the created-Stripe user. Check if any of them are missing fields.
  3. Add console.log logging in the webhook and in the create-checkout-session endpoint until you figure out what the issue is.
  4. If you need to re-test the flow, you will probably need to clear your database. You can do that with Prisma using npx prisma migrate reset.

Conclusion + Next Steps

Congratulations! I hope you were able to successfully implement this complex integration. You now have a system for registering users and collecting recurring payments from them. That's basically a super-power in the web world 🦸‍♀️🦸‍♂️

There are a few more steps you would need to take before you can "go live" with this system:

  1. You need to handle the Stripe events for users cancelling their subscriptions or failing to pay (credit card declined, for example). You can handle those cases in the webhooks/stripe.ts file, by adding more cases where we currently have the comment // ... handle other event types. Here, you should also handle the case when a payment fails after a subscription is created. See this Stripe doc page for more details.

  2. You need to host your site, so that you can connect Stripe to the hosted webhook instead of the localhost forwarded-webhook. You can add the deployed webhook URL here: https://dashboard.stripe.com/test/webhooks.

  3. For the redirection URLs to support both development and production, in the create-checkout-session endpoint, you can use a condition like const isProd = process.env.NODE_ENV === 'production' and then use the isProd variable to choose the redirection URL - either localhost or your deployed site.

  4. Style the sign-in page. Right now it's pretty dark and bland :)

There are lots more customizations you can make here of course, such as including extra metadata in the Stripe objects, or connecting the payment plans to organizations instead of accounts, or adding multiple tiers of pricing and database fields to track that.

No matter where you go from here, you should now have a basic framework for the authentication and payment parts of your subscription services!

Connect with Me

Thanks for reading! I hope this saved you some time and frustration from the process I went through to get this all set up.

Feel free to leave a comment if you have a question, or message me on Twitter. I would also really appreciate if you could check out the project I'm working on which inspired this article, Envious 🤩

Let me know what tutorial you'd like to see next!

Top comments (8)

Collapse
 
mcnaveen profile image
MC Naveen

Best article so far ❤️

Now it's time for me to use Mongodb instead of Postgres

Collapse
 
eliastouil profile image
eliastouil • Edited

Thank you so much for this article!!

Some notes from 2023

Using appdir in NextJs13

In the schema, the customer id must be @unique to work with prisma.user.update({where....}) queries

model User {
   ...
   stripeCustomerId        String?              @unique
   isActive                         Boolean            @default(false)
}
Enter fullscreen mode Exit fullscreen mode

in

Next, follow Step 3 here to connect Stripe to your local server. Use the URL http://localhost:YOUR_PORT/api/webhooks/stripe which we will create
Enter fullscreen mode Exit fullscreen mode

The step3 url is broken :'/


update to the API route

  • file must be named route
  • exported function named "POST"
  • use NextResponse to return status

For appDir, I placed this file in : ./app/api/stripe/create-checkout-session/route.ts

import PUBLIC_SITE_URL from '@/lib/siteUrl';
import type { NextApiRequest, NextApiResponse } from 'next';
import { getServerSession } from 'next-auth';
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { authOptions } from '../../auth/[...nextauth]/route';

export const POST = async (req: NextApiRequest, res: NextApiResponse) => {
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
        apiVersion: '2022-11-15',
    });

    // This object will contain the user's data if the user is signed in
    const session = await getServerSession(authOptions);
    console.log('session user : ' + session?.user);

    // Error handling
    if (!session?.user || !session.user.id || !session.user.stripeCustomerId) {
        return NextResponse.json({ message: 'no-access' }, { status: 401 });
    }

    const checkoutSession = await stripe.checkout.sessions.create({
        mode: 'subscription',
        /* This is where the magic happens - this line will automatically link this Checkout page to the existing customer we created when the user signed-up, so that when the webhook is called our database can automatically be updated correctly.*/
        customer: session.user.stripeCustomerId,
        line_items: [
            {
                price: process.env.CREATOR_PLAN_BASE_PRICE_ID, // THE PRICE ID YOU CREATED EARLIER,
                quantity: 1,
            },
        ],
        // {CHECKOUT_SESSION_ID} is a string literal which the Stripe SDK will replace; do not manually change it or replace it with a variable!
        success_url: `${PUBLIC_SITE_URL}?session_id={CHECKOUT_SESSION_ID}`,
        cancel_url: `${PUBLIC_SITE_URL}?cancelledPayment=true`,
        subscription_data: {
            metadata: {
                // This isn't 100% required, but it helps to have so that we can manually check in Stripe for whether a customer has an active subscription later, or if our webhook integration breaks.
                payingUserId: session.user.id,
            },
        },
    });

    if (!checkoutSession.url) {
        return NextResponse.json({ message: 'stripe-error' }, { status: 500 });
    }

    // Return the newly-created checkoutSession URL and let the frontend render it
    return NextResponse.json({ redirectUrl: checkoutSession.url });
};

Enter fullscreen mode Exit fullscreen mode

An example of a hook that will work with AppDir

github.dev/shadcn-ui/taxonomy/blob...

Collapse
 
wubo profile image
Wu

Idiot issue, in the create-checkout-session.ts file, I output the session in the response, but it returns 404 and null.

// This object will contain the user's data if the user is signed in
const session = await getSession({ req });
// Error handling
if (!session?.user) {
return res.status(401).json({
error: {
code: 'no-access',
message: 'You are not signed in.',
session: session // return null
},
});
}

But on the homepage, I output session data, it's normal.

const {data} = useSession();
console.log(data);

Can you give me any tips? Thanks!!!!

Collapse
 
sethmckilla profile image
Seth

Excellent article! 👏👏👏

I think there's one minor issue in the article after setting it up on my end. I think the stripeCustomerId value should be included in the next-auth session callback. Along those same lines, is there a reason why your querying the db for the database for the isActive value? I've got the following session callback (note: I'm using tiers instead of a single sub) and seems to be working fine so far 🤞 I might be missing something though. Thanks again for the awesome resource!

  callbacks: {
    async session({ session, user }) {
      const stripeSubTier = getStripeSubTier(user.stripeSubId as string);

      session!.user!.id = user.id;
      session!.user!.stripeCustomerId = user.stripeCustomerId as string;
      session!.user!.stripeSubTier = stripeSubTier as string;

      return session;
    },
  },
Enter fullscreen mode Exit fullscreen mode
Collapse
 
andriusmv profile image
Andres Moreno Vasquez

Hey Andrew, great article! Just one thing: how do you add components to conditionally render parts of your application? I mean, how do you accomplish something like this:
membershipType == Gold? :
Thank you!

Collapse
 
sethmckilla profile image
Seth

Hey I've actually done just that in my application. I'm going to attempt to summarize as succintly as possible. To start, instead of a boolean value for isActive in the schema, I switched this to the subId from stripe (i.e. price_...):

// prisma/schema.prisma

model User {
  id               String    @id @default(cuid())
  name             String?
  email            String?   @unique
  emailVerified    DateTime?
  image            String?
  stripeCustomerId String?   @unique
  stripeSubId      String?
  accounts         Account[]
  sessions         Session[]
}
Enter fullscreen mode Exit fullscreen mode

Then I added a config file where I store info about the tiers and prices (which I've created on Stripe the same way as Andrew did above):

// config/index.ts

export const CURRENCY = "usd";
export const YEARLY_DISCOUNT = 0.8;
export const MONTHLY_PRICES = {
  MONTHLY_SINGLE: 10.0,
  MONTHLY_MULTI: 25.0,
  MONTHLY_UNLIMITED: 50.0,
};
export const MONTHLY_PRICING = {
  ...MONTHLY_PRICES,
  YEARLY_SINGLE: MONTHLY_PRICES["MONTHLY_SINGLE"] * YEARLY_DISCOUNT,
  YEARLY_MULTI: MONTHLY_PRICES["MONTHLY_MULTI"] * YEARLY_DISCOUNT,
  YEARLY_UNLIMITED: MONTHLY_PRICES["MONTHLY_UNLIMITED"] * YEARLY_DISCOUNT,
};
export const TRIAL_PERIOD_DAYS = 14;
export const SUBSCRIPTION_IDS = {
  STRIPE_YEARLY_UNLIMITED_SUB_ID: process.env.STRIPE_YEARLY_UNLIMITED_SUB_ID,
  STRIPE_YEARLY_MULTI_SUB_ID: process.env.STRIPE_YEARLY_MULTI_SUB_ID,
  STRIPE_YEARLY_SINGLE_SUB_ID: process.env.STRIPE_YEARLY_SINGLE_SUB_ID,
  STRIPE_MONTHLY_UNLIMITED_SUB_ID: process.env.STRIPE_MONTHLY_UNLIMITED_SUB_ID,
  STRIPE_MONTHLY_MULTI_SUB_ID: process.env.STRIPE_MONTHLY_MULTI_SUB_ID,
  STRIPE_MONTHLY_SINGLE_SUB_ID: process.env.STRIPE_MONTHLY_SINGLE_SUB_ID,
};
Enter fullscreen mode Exit fullscreen mode

Note I've also got a yearly discount too, I like having all this in a config file so I can easily change these values in one spot and the entire app updates based on the values. Then I use the subType the user selects on the frontend to determine which subId to use in the checkout session:

// pages/api/stripe/checkout-sessions.ts
...
const subType: string = req.body.subType;

const checkoutSession = await stripe.checkout.sessions.create({
  mode: "subscription",
  customer: session.user.stripeCustomerId,
  metadata: {
    payingUserId: session.user.id,
  },
  subscription_data: {
    trial_period_days,
  },
  line_items: [
    {
      price:
        SUBSCRIPTION_IDS[
          `STRIPE_${subType}_SUB_ID` as keyof typeof SUBSCRIPTION_IDS
        ],
      quantity: 1,
    },
  ],
  success_url: `${req.headers.origin}/profile/setup?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${req.headers.origin}/profile/signup?cancelledPayment=true`,
});

if (!checkoutSession.url) throw new Error("No checkout session url!");

return res.status(200).json(checkoutSession);
...
Enter fullscreen mode Exit fullscreen mode

Then I created a helper function to determine the pricing tier from the subId that I store in the database:

// utils/stripe.ts

export const getStripeSubTier = (subId: string) => {
  const subTier = Object.keys(SUBSCRIPTION_IDS).find(
    (key) => SUBSCRIPTION_IDS[key as keyof typeof SUBSCRIPTION_IDS] === subId
  );
  return subTier?.split("_")[2];
};
Enter fullscreen mode Exit fullscreen mode

The webhook also needs to be updated:

// api/webhooks/stripe.ts
...
case "customer.subscription.created": {
  const subscription = event.data.object as Stripe.Subscription;

  await prisma.user.update({
    where: {
      stripeCustomerId: subscription.customer as string,
    },
    data: {
      stripeSubId: subscription.items.data[0]?.price.id,
    },
  });
  break;
}
...
Enter fullscreen mode Exit fullscreen mode

Then finally add that to the user session in the next-auth session callback:

// pages/api/auth/[...nextauth].ts
...
  callbacks: {
    async session({ session, user }) {
      const stripeSubTier = getStripeSubTier(user.stripeSubId as string);

      session!.user!.id = user.id;
      session!.user!.stripeCustomerId = user.stripeCustomerId as string;
      session!.user!.stripeSubTier = stripeSubTier as string;

      return session;
    },
  },
...
Enter fullscreen mode Exit fullscreen mode

Hopefully this makes sense, let me know if you have any questions!

Collapse
 
sagarjani profile image
Sagar Jani

Great article, did you explore using the pricing tables instead of creating your own UI with stripe subscription ?

Collapse
 
sheldonniu profile image
DaiNiu

Thanks, amazing