DEV Community

Cover image for Stripe Subscription Lifecycle in Next.js — The Complete Developer Guide (2026)
Esimit Karlgusta
Esimit Karlgusta

Posted on • Originally published at zero-to-saas.collabtower.com

Stripe Subscription Lifecycle in Next.js — The Complete Developer Guide (2026)

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.

Stripe payment integration checkout page illustration


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:

  1. Stripe account with at least one Product and Price created in the dashboard
  2. Stripe Node SDK installed: npm install stripe
  3. Environment variables configured:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
Enter fullscreen mode Exit fullscreen mode
  1. A users collection in MongoDB with a field for stripeCustomerId and subscriptionStatus

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);
Enter fullscreen mode Exit fullscreen mode

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.

Developer coding on laptop with code editor open


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 });
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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.

SaaS dashboard showing analytics and user stats


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;
}
Enter fullscreen mode Exit fullscreen mode

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}</>;
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Developer building a SaaS app using modern web technologies


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.id to deduplicate if needed.
  • Retry tolerance: Your webhook handler must return 200 quickly. 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_due users 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 1checkout.session.completed → Marcus's status set to active, currentPeriodEnd = April 1
  • April 1invoice.paid → Status remains active, currentPeriodEnd updated to May 1
  • May 1invoice.payment_failed → Card expired. Status → past_due. Stripe retries on May 4, May 8
  • May 8invoice.paid (retry success) → Status back to active
  • May 20 — Marcus opens portal, cancels. customer.subscription.updated fires with cancel_at_period_end: true
  • June 1customer.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

  1. ✅ Create a Stripe Product and Price in your dashboard
  2. ✅ Add stripeCustomerId and subscriptionStatus fields to your User model
  3. ✅ Build the /api/stripe/checkout route
  4. ✅ Deploy the webhook handler and verify with Stripe CLI
  5. ✅ Add subscription status check to your dashboard layout
  6. ✅ Wire up the Customer Portal endpoint
  7. 🔜 Add an email notification on invoice.payment_failed
  8. 🔜 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)