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:
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.