Integrating Stripe Subscriptions in Next.js with Webhooks
Adding payments is one of the biggest milestones when building a SaaS. Stripe makes it powerful yet straightforward and when combined with Next.js 16+, you can implement both one-time payments and recurring subscriptions securely and efficiently.
This guide shows you exactly how to integrate Stripe into your Next.js app (the same pattern used in SaaSJet), including checkout sessions, webhook handling for subscription events, and customer management all with best practices for production.
By the end, you'll have a fully working payment system ready for launch.
Why This Setup Works So Well for SaaS
- Server-side security: API keys never exposed to the client
- Reliable event handling: Webhooks ensure subscription status stays in sync
- Supports both models: One-time payments and monthly/yearly subscriptions
- Scalable: Works with Stripe test mode → live mode seamlessly
Step 1: Set Up Stripe Account & Keys
- Go to stripe and create an account
- In the dashboard, go to Developers → API keys
- Copy your Publishable key (
pk_test_...orpk_live_...) and Secret key (sk_test_...) - Add them to your
.env.local:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # We'll get this in Step 4
Use test keys during development they won't charge real cards.
Step 2: Create Pricing Plans in Stripe Dashboard
- Go to Products → Add product
- Create plans like:
- "Free" → $0
- "Pro" → Recurring → $9/year (with discount)
- "Lifetime Access" → One-time → $49
Note down the Price IDs (e.g., price_1ABC123...) you'll need them in code.
Step 3: Create Checkout Sessions (Frontend + API Route)
Frontend: Trigger Checkout
Create a button or pricing card component:
// app/pricing/page.tsx or component
"use client";
import { Card, CardContent } from './ui/card'
import { Button } from './ui/button'
import { pricingPlans } from '@/constants/constant'
import { useSession } from '@/lib/auth-client'
import { useEffect, useState } from 'react';
// we're extending the Session user type to include stripeCustomerId
interface UserWithStripe {
name: string;
email: string;
id: string;
stripeCustomerId?: string;
subscriptionPlan: string;
}
const PricingSection = () => {
const [fullUserData, setFullUserData] = useState<UserWithStripe | null>(null)
const { data: session } = useSession()
const currentPlan = fullUserData?.subscriptionPlan
const isFreeUser = currentPlan?.toLowerCase() === "free";
const isProUser = currentPlan?.toLowerCase() === "pro";
const isLifetimeUser = currentPlan?.toLowerCase() === "lifetime";
async function handleCheckout(plan: string) {
if (!session?.user) {
alert("Not LoggedIn Yet!")
}
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: session?.user.id, plan: plan.toLowerCase() }),
});
const data = await res.json();
if (data.url) window.location.href = data.url;
}
async function handleManageSubscription() {
if (!session?.user) {
alert("Not LoggedIn Yet!")
}
const res = await fetch("/api/manage-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: session?.user.id }),
});
const data = await res.json();
if (data.url) window.location.href = data.url;
}
useEffect(() => {
if (!session?.user.id) return;
const fetchFullUser = async () => {
if (!session?.user) {
alert("Not LoggedIn Yet!")
}
const res = await fetch("/api/auth/get-full-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: session?.user.id })
})
if (!res.ok) {
console.log("ERROR: fetching full user data", res.body);
}
const data = await res.json()
setFullUserData(data.user)
}
fetchFullUser()
}, [session?.user])
return (
<section className="max-w-6xl mx-auto px-6 py-24">
<h2 className="text-3xl font-semibold text-center mb-16">Simple Pricing</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{pricingPlans.map((plan, index) => {
const isFreePlan = plan.name === "Free";
const isProPlan = plan.name === "Pro";
// this is just a starting point its up to you as per your stripe billings logic
const disableCheckout =
isLifetimeUser || // lifetime users can't buy anything
(isProUser && !isProPlan) || // pro users can manage only their own plan
(isFreeUser && isFreePlan); // free users can't buy free plan again
const showManageButton =
isProUser && isProPlan;
return (
<Card key={index} className={`${plan.cardStyle} backdrop-blur-xl rounded-2xl`}>
<CardContent className="p-8">
<h3 className="text-2xl font-semibold mb-4 text-white">{plan.name}</h3>
<p className="text-4xl font-bold mb-6 text-white/90">{plan.price}</p>
<ul className="space-y-3 text-white/70 mb-6">
{plan.features.map((feature, featureIndex) => (
<li key={featureIndex}>✔ {feature}</li>
))}
</ul>
{/* LIFETIME USER VIEW */}
{isLifetimeUser ? (
<Button
disabled
className="w-full bg-white text-black rounded-xl opacity-70 cursor-not-allowed"
>
Lifetime Access
</Button>
) : showManageButton ? (
<Button
onClick={handleManageSubscription}
className="w-full bg-white text-black hover:bg-white/90 rounded-xl cursor-pointer"
>
Manage Subscription
</Button>
) : (
<Button
onClick={() => handleCheckout(plan.name)}
disabled={disableCheckout}
className="w-full bg-white text-black hover:bg-white/90 rounded-xl disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{plan.buttonText}
</Button>
)}
{/* Only for Pro plan when user is Pro */}
{showManageButton && (
<p className="text-white/60 text-sm text-center mt-2">
Cancel or change your plan
</p>
)}
</CardContent>
</Card>
);
})}
</div>
</section>
)
}
export default PricingSection
Backend: Create Session API Route
// src/app/api/checkout/route.ts
import prisma from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-11-17.clover",
});
export async function POST(req: NextRequest) {
const { userId, plan } = await req.json();
if (!userId || !plan) {
return NextResponse.json(
{ error: "Missing params" },
{ status: 400 }
)
}
// Get user
const user = await prisma.user.findUnique({ where: { id: userId } });
// handle user not found
if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
// Create Stripe customer if not exists
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({ email: user.email });
customerId = customer.id;
await prisma.user.update({ where: { id: userId }, data: { stripeCustomerId: customerId } });
}
// Map plan to Stripe price ID
const priceMap: Record<string, string> = {
pro: process.env.STRIPE_PRICE_PRO!,
lifetime: process.env.STRIPE_PRICE_LIFETIME!,
};
// Validate plan and get price ID
const lineItemPriceId = priceMap[plan];
if (!lineItemPriceId) {
return NextResponse.json(
{ error: "Invalid plan" },
{ status: 400 }
);
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
mode: plan === "lifetime" ? "payment" : "subscription",
payment_method_types: ["card"],
line_items: [{ price: lineItemPriceId, quantity: 1 }], // you can extend line items as per needs like for a ecommerce cart etc
customer: customerId,
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`, // you can redirect users where ever you want like /success page
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}`, // home route or maybe cancel page as per your needs
});
return NextResponse.json({ url: session.url });
}
Step 4: Handle Webhooks (The Most Important Part)
Webhooks keep your database in sync when:
- Subscription starts/renews
- Payment fails
- Customer cancels
Create Webhook Endpoint
// src/app/api/webhook/route.ts
import { NextResponse } from "next/server";
import Stripe from "stripe";
import prisma from "@/lib/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const preferredRegion = "auto";
export const maxDuration = 60;
export const bodyParser = false; // IMPORTANT for Stripe signature validation
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2025-11-17.clover" });
function isSubscriptionInvoice(data: any) {
return !!data.subscription;
}
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
console.error("⚠️ Webhook signature verification failed.", err.message);
return new NextResponse("Invalid signature", { status: 400 });
}
const data = event.data.object as any;
try {
switch (event.type) {
case "checkout.session.completed": {
// for subscriptions payment
if (data.mode === "subscription") {
const subscription = await stripe.subscriptions.retrieve(data.subscription);
await updateUserSubscription(subscription.customer as string, subscription);
// and it is for lifetime payment!
} else if (data.mode === "payment") {
await prisma.user.update({
where: { stripeCustomerId: data.customer },
data: {
subscriptionStatus: "active",
subscriptionPlan: "lifetime",
currentPeriodEnd: null,
},
});
}
break;
}
// it is not hard at all simply checking wheather a new subscription invoice is paid or not
case "invoice.paid": {
if (!isSubscriptionInvoice(data)) break;
const subscription = await stripe.subscriptions.retrieve(data.subscription);
await updateUserSubscription(subscription.customer as string, subscription);
break;
}
case "customer.subscription.updated":
case "customer.subscription.created":
case "customer.subscription.deleted": {
const subscription = data;
await updateUserSubscription(subscription.customer as string, subscription);
break;
}
}
} catch (err) {
console.error("Webhook handling error:", err);
return new NextResponse("Webhook error", { status: 500 });
}
return new NextResponse("OK", { status: 200 });
}
async function updateUserSubscription(customerId: string, subscription: any) {
const plan = subscription.items.data[0].price.id === process.env.STRIPE_PRICE_PRO
? "pro"
: "unknown";
const periodEnd = subscription.current_period_end
? new Date(Number(subscription.current_period_end) * 1000)
: null;
await prisma.user.updateMany({
where: { stripeCustomerId: customerId },
data: {
subscriptionStatus: subscription.status,
subscriptionPlan: plan,
currentPeriodEnd: periodEnd, // this might not work consider testing before real payments or alternatively user can mange subscriptions from portal access via direct url
},
});
}
Get your STRIPE_WEBHOOK_SECRET from the Stripe dashboard → Webhooks → Add endpoint → Copy signing secret.
Step 5: Sync Data with Your Database (Prisma Example)
Update your Prisma schema:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
name String
email String
emailVerified Boolean @default(false)
image String?
stripeCustomerId String? @unique
subscriptionStatus String? // active, trialing, past_due, canceled, unpaid, null for free
subscriptionPlan String? // free, pro, lifetime
currentPeriodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
}
Final Tips for Production
- Always verify webhook signatures
- Use Stripe test mode + test cards
(4242 4242...)during development - Add success page to thank users and redirect to dashboard
- Protect routes using subscription status from DB
- Handle edge cases: failed payments, upgrades/downgrades
That's It!
You now have a complete, secure Stripe integration supporting both one-time and recurring payments with webhooks ensuring perfect sync.
SaaSJet comes with all of this pre-built and configured: checkout pages, webhook handlers, protected routes, and billing dashboard so you can focus on your product instead of payment plumbing.
Build this faster with SaaSJet → clone the repo saasjet
Happy coding! Published: December 27, 2025