December 27, 202512 min read

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_... or pk_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