Category: Stripe Payments and Billing
Primary Keyword: Stripe subscription lifecycle Next.js
Level: Intermediate
Most tutorials show you how to add Stripe to a Next.js app. Few show you what happens after the user subscribes — the renewals, failures, cancellations, and recovery flows that make or break a real SaaS business.
This guide walks you through the complete Stripe subscription lifecycle: from checkout session to webhook handling, customer portal integration, and automated churn recovery. If you're building a production SaaS, this is the article you wish you had on day one.
Why the Lifecycle Matters More Than the Checkout
When developers first integrate Stripe, they focus on the checkout form. Makes sense — you want to see money coming in. But a SaaS business lives and dies by what happens between payments.
Here's the reality of a subscription over 12 months:
- A user subscribes (checkout)
- Stripe charges them monthly (renewals)
- A card expires or gets declined (payment failure)
- Stripe retries and sends dunning emails (recovery)
- The user wants to upgrade or downgrade (plan change)
- The user cancels (churn)
- The subscription ends at period end (access revocation)
If your app doesn't handle each of these events, you'll have users with broken access, missed revenue, and no way to debug any of it. Let's fix that.
Setting Up: What You Need Before Writing Code
Before diving into webhooks and lifecycle events, make sure your Next.js project has these pieces in place:
- Stripe account with at least one Product and Price created in the dashboard
-
Stripe Node SDK installed:
npm install stripe - Environment variables configured:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
-
A
userscollection in MongoDB with a field forstripeCustomerIdandsubscriptionStatus
Your user schema in Mongoose might look like this:
// models/User.js
import mongoose from 'mongoose';
const UserSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
stripeCustomerId: { type: String },
subscriptionId: { type: String },
subscriptionStatus: {
type: String,
enum: ['active', 'past_due', 'canceled', 'trialing', 'incomplete', null],
default: null,
},
currentPeriodEnd: { type: Date },
});
export default mongoose.models.User || mongoose.model('User', UserSchema);
This schema is the source of truth your app reads from. Stripe is the source of truth Stripe reads from. Your webhooks keep them in sync.
Step 1 — Creating the Checkout Session
When a user clicks "Subscribe," you create a Stripe Checkout Session via a Server Action or API route.
// app/api/stripe/checkout/route.js
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/authOptions';
import User from '@/models/User';
import connectDB from '@/lib/mongodb';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
await connectDB();
const session = await getServerSession(authOptions);
if (!session) return new Response('Unauthorized', { status: 401 });
const user = await User.findOne({ email: session.user.email });
// Create or retrieve the Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({ email: user.email });
customerId = customer.id;
await User.updateOne({ email: user.email }, { stripeCustomerId: customerId });
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
payment_method_types: ['card'],
mode: 'subscription',
line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return Response.json({ url: checkoutSession.url });
}
Key point: Always attach a customer ID to the session. This links the Stripe customer to your user record and makes every downstream webhook event traceable back to a user in your database.
Step 2 — Handling Webhooks: The Heart of the Lifecycle
Webhooks are how Stripe tells your app what happened. You don't poll Stripe — Stripe calls you. This means your webhook endpoint must be fast, idempotent, and reliable.
Here's the webhook handler that covers the full subscription lifecycle:
// app/api/stripe/webhook/route.js
import Stripe from 'stripe';
import { headers } from 'next/headers';
import connectDB from '@/lib/mongodb';
import User from '@/models/User';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const body = await req.text();
const signature = headers().get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return new Response(`Webhook error: ${err.message}`, { status: 400 });
}
await connectDB();
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
await User.updateOne(
{ stripeCustomerId: session.customer },
{ subscriptionId: session.subscription, subscriptionStatus: 'active' }
);
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
await User.updateOne(
{ stripeCustomerId: invoice.customer },
{
subscriptionStatus: 'active',
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
}
);
break;
}
case 'invoice.payment_failed': {
await User.updateOne(
{ stripeCustomerId: event.data.object.customer },
{ subscriptionStatus: 'past_due' }
);
// Optionally: trigger email notification here
break;
}
case 'customer.subscription.deleted': {
await User.updateOne(
{ stripeCustomerId: event.data.object.customer },
{ subscriptionStatus: 'canceled', subscriptionId: null }
);
break;
}
case 'customer.subscription.updated': {
const sub = event.data.object;
await User.updateOne(
{ stripeCustomerId: sub.customer },
{
subscriptionStatus: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
}
);
break;
}
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
The Five Events You Must Handle
| Event | What It Means | What You Do |
|---|---|---|
checkout.session.completed |
User subscribed | Activate account |
invoice.paid |
Renewal succeeded | Extend currentPeriodEnd
|
invoice.payment_failed |
Card declined | Set status to past_due
|
customer.subscription.deleted |
Sub canceled or expired | Revoke access |
customer.subscription.updated |
Plan changed or paused | Sync status and dates |
Missing any of these means your database will drift out of sync with Stripe — and users will either have access they shouldn't, or lose access they paid for.
Step 3 — Protecting Routes Based on Subscription Status
Once your webhook is syncing subscription status to MongoDB, protecting routes is straightforward. In your middleware or layout server component, check the user's subscriptionStatus:
// lib/getSubscriptionStatus.js
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/authOptions';
import User from '@/models/User';
import connectDB from '@/lib/mongodb';
export async function getSubscriptionStatus() {
await connectDB();
const session = await getServerSession(authOptions);
if (!session) return null;
const user = await User.findOne({ email: session.user.email });
return user?.subscriptionStatus ?? null;
}
In your dashboard layout:
// app/dashboard/layout.js
import { redirect } from 'next/navigation';
import { getSubscriptionStatus } from '@/lib/getSubscriptionStatus';
export default async function DashboardLayout({ children }) {
const status = await getSubscriptionStatus();
if (status !== 'active' && status !== 'trialing') {
redirect('/pricing?reason=inactive');
}
return <>{children}</>;
}
This is clean, server-side, and requires zero client JavaScript. The redirect happens before the page renders.
Step 4 — The Customer Portal: Self-Service Billing
Instead of building a custom cancel or upgrade flow, use Stripe's hosted Customer Portal. It handles plan changes, payment method updates, and cancellations out of the box.
// app/api/stripe/portal/route.js
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/authOptions';
import User from '@/models/User';
import connectDB from '@/lib/mongodb';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
await connectDB();
const session = await getServerSession(authOptions);
const user = await User.findOne({ email: session.user.email });
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
});
return Response.json({ url: portalSession.url });
}
Add a "Manage Billing" button in your dashboard that calls this endpoint and redirects:
'use client';
async function openPortal() {
const res = await fetch('/api/stripe/portal', { method: 'POST' });
const { url } = await res.json();
window.location.href = url;
}
<button onClick={openPortal} className="btn btn-outline">
Manage Billing
</button>
When a user cancels through the portal, Stripe fires customer.subscription.updated (with cancel_at_period_end: true) and eventually customer.subscription.deleted — both of which your webhook already handles.
How This Fits Into the Zero to SaaS Journey
The subscription lifecycle is where your SaaS becomes a real business. Authentication gets users in the door. The dashboard keeps them engaged. But billing is where value exchange happens — and where most indie SaaS apps break down in subtle, expensive ways.
If you're following the Zero to SaaS course, this lesson sits at the midpoint: after authentication and database setup, before deployment. Getting this right before you ship means you won't be debugging ghost subscriptions at 2am after your first launch.
For a deeper look at setting up subscriptions from scratch, check out Stripe Subscriptions in Next.js and Build SaaS with Next.js and Stripe.
Common Mistakes to Avoid
1. Not verifying webhook signatures
Always use stripe.webhooks.constructEvent(). Without this, anyone can POST to your webhook endpoint and fake events.
2. Using checkout.session.completed as the only activation trigger
Checkout fires once. invoice.paid fires every renewal. If you only listen to checkout, your users lose access after the first billing cycle.
3. Storing subscription data only in Stripe
Your app should have a local copy of subscription status. Querying Stripe on every request adds latency and rate limit risk.
4. Not handling cancel_at_period_end
When a user cancels, Stripe sets this flag to true but keeps the subscription active until the period ends. If you immediately revoke access on cancel, you're giving a worse experience than Stripe's own dashboard promises the user.
5. Forgetting to test with the Stripe CLI
Run stripe listen --forward-to localhost:3000/api/stripe/webhook locally. Without this, you'll be deploying blind.
Pro Tips for Production
-
Idempotency: Stripe can send the same event more than once. Use
event.idto deduplicate if needed. -
Retry tolerance: Your webhook handler must return
200quickly. Move heavy work (emails, database jobs) to a background queue. - Monitor failed webhooks: Stripe Dashboard > Developers > Webhooks shows every delivery attempt and failure reason. Check it after every deploy.
-
Grace period logic: Give
past_dueusers 3–5 days before revoking access. Stripe's Smart Retries often recover these automatically.
Real-World Example: TaskFlow SaaS
Imagine you're building TaskFlow — a project management SaaS with a $19/month Pro plan.
A user named Marcus subscribes on March 1st. Here's what your system processes over the next 60 days:
-
March 1 —
checkout.session.completed→ Marcus's status set toactive,currentPeriodEnd= April 1 -
April 1 —
invoice.paid→ Status remainsactive,currentPeriodEndupdated to May 1 -
May 1 —
invoice.payment_failed→ Card expired. Status →past_due. Stripe retries on May 4, May 8 -
May 8 —
invoice.paid(retry success) → Status back toactive -
May 20 — Marcus opens portal, cancels.
customer.subscription.updatedfires withcancel_at_period_end: true -
June 1 —
customer.subscription.deleted→ Status →canceled. Dashboard access removed.
Every step is automatic. Zero manual intervention. That's the power of a properly wired lifecycle.
Action Plan: What to Build Next
- ✅ Create a Stripe Product and Price in your dashboard
- ✅ Add
stripeCustomerIdandsubscriptionStatusfields to your User model - ✅ Build the
/api/stripe/checkoutroute - ✅ Deploy the webhook handler and verify with Stripe CLI
- ✅ Add subscription status check to your dashboard layout
- ✅ Wire up the Customer Portal endpoint
- 🔜 Add an email notification on
invoice.payment_failed - 🔜 Build a usage dashboard showing
currentPeriodEnd
Wrapping Up
The Stripe subscription lifecycle isn't just a technical concern — it's your revenue pipeline. Every event maps to a moment in your customer's journey, and how you handle each one determines whether your SaaS feels professional or broken.
You now have the complete picture: checkout, renewals, failures, recovery, plan changes, and cancellations — all wired to your Next.js App Router app through a single, robust webhook handler.
If you want to go deeper and build this end-to-end alongside a full SaaS app — with authentication, a MongoDB backend, Tailwind UI, and Vercel deployment — the Zero to SaaS course walks you through every step in sequence, with real code and real decisions.
The billing layer is ready. Time to ship.




Top comments (0)