How I handle Stripe Subscriptions in my Nextjs SaaS Boilerplate

How I handle Stripe Subscriptions in my Nextjs SaaS Boilerplate

Hello Everyone,

I've been working on a SaaS boilerplate for NextJs called Indiespace, which is a part of most of my projects. Let me guide you through how this boilerplate manages Stripe Subscriptions.

Boilerplate Technologies:

  • NextJs

  • NextAuth

  • Prisma

  • Resend (For Transactional Emails)

  • Stripe

  • TailwindCSS

  • Shadcn UI

So for the database, the User model in the schema looks something like this:

Indiespace user model

In this setup, we save several key details:

  • customer_id

  • subscription_id

  • price_id

  • subscription_end_date

When a user subscribes to a plan, the following webhook steps in to update these keys in the database:

export async function POST(req: Request) {
    const body = await req.text();
    const signature = headers().get("Stripe-Signature") as string;

    let event: Stripe.Event;

    try {
        event = stripe.webhooks.constructEvent(
            body,
            signature,
            process.env.STRIPE_WEBHOOK_SECRET!,
        );
    } catch (err: any) {
        return new NextResponse("WEBHOOK ERROR:" + err.message, { status: 400 });
    }

    const session = event.data.object as Stripe.Checkout.Session;

    if (event.type === "checkout.session.completed") {
        const subscription = await stripe.subscriptions.retrieve(
            session.subscription as string,
        );

        if (!session?.metadata?.userId) {
            return new NextResponse("No user id in session", { status: 400 });
        }

        await prisma.user.update({
            where: {
                id: session?.metadata?.userId,
            },
            data: {
                stripeSubscriptionId: subscription.id,
                stripeCustomerId: subscription.customer as string,
                stripePriceId: subscription.items.data[0].price.id,
                stripeCurrentPeriodEnd: new Date(
                    subscription.current_period_end * 1000,
                ),
            },
        });
    }

    if (event.type === "invoice.payment_succeeded") {
        const subscription = await stripe.subscriptions.retrieve(
            session.subscription as string,
        );

        await prisma.user.update({
            where: {
                stripeSubscriptionId: subscription.id,
            },
            data: {
                stripePriceId: subscription.items.data[0].price.id,
                stripeCurrentPeriodEnd: new Date(
                    subscription.current_period_end * 1000,
                ),
            },
        });
    }
    return new NextResponse(null, { status: 200 });
}

Following that, to verify a user's subscription status, I've created a utility function. This function provides all subscription details and includes a isPro boolean to streamline frontend validations:

const GRACE_PERIOD_MS = 86_400_000;

export async function getUserSubscription(
    userId: string,
): Promise<UserSubscriptionPlan> {
    const user = await prisma.user.findUnique({
        where: {
            id: userId,
        },
        select: {
            stripeSubscriptionId: true,
            stripeCustomerId: true,
            stripePriceId: true,
            stripeCurrentPeriodEnd: true,
        },
    });

    if (!user) {
        throw new Error("User not found");
    }

    const isPro =
        user.stripePriceId &&
        user.stripeCurrentPeriodEnd?.getTime()! + GRACE_PERIOD_MS > Date.now();

    return {
        ...user,
        isPro: !!isPro,
    };
}

This setup covers three main steps: a user picks a plan, pays, and then gets the content. It's a general view, but there's a lot more happening in the background. If you like this approach, check out Indiespace and give it a try.

To explore the boilerplate's features and understand how I've structured the codebase, visit Indiespace Docs.