← Back to blog
·7 min read·By Utkarsh Singh

Stripe Subscriptions in Next.js: Billing, Entitlements and Paywalls Done Right

A complete guide to Stripe subscription billing in Next.js — checkout, subscription lifecycle, feature gating by plan, paywalls, and entitlement syncing.

nextjsstripesubscriptionsbillingsaas

Stripe Subscriptions in Next.js: Billing, Entitlements and Paywalls Done Right

Getting Stripe subscriptions working is one thing. Getting them working correctly — with proper entitlement syncing, paywalls that actually hold, and a subscription lifecycle that handles cancellations, upgrades, and failed payments — is a different problem entirely.

This guide covers the full picture. Not just the checkout flow, but everything that happens after a user pays.


The Full Subscription Lifecycle

Most tutorials cover the happy path: user pays, they get access. But subscriptions have a full lifecycle that you need to handle:

  1. Trial (optional) — user gets access before paying
  2. Active — user is paying, has full access
  3. Past due — payment failed, Stripe is retrying
  4. Cancelled — user cancelled, access should end at period end
  5. Expired — subscription ended, user downgraded to free

If you only handle state 1 and 2, your product will leak access and potentially charge users for nothing.


Step 1: Create Stripe Products and Prices

Do this once in the Stripe dashboard or via API:

// scripts/setup-stripe.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

async function setup() {
  // Create products
  const starterProduct = await stripe.products.create({
    name: "ZeroDrag Starter",
    description: "For solo builders and MVPs",
  });

  const proProduct = await stripe.products.create({
    name: "ZeroDrag Pro",
    description: "Ship production apps faster",
  });

  // Create monthly prices
  const starterPrice = await stripe.prices.create({
    product: starterProduct.id,
    currency: "usd",
    unit_amount: 1900, // $19/month
    recurring: { interval: "month" },
  });

  const proPrice = await stripe.prices.create({
    product: proProduct.id,
    currency: "usd",
    unit_amount: 2900, // $29/month
    recurring: { interval: "month" },
  });

  console.log("STRIPE_STARTER_PRICE_ID=" + starterPrice.id);
  console.log("STRIPE_PRO_PRICE_ID=" + proPrice.id);
}

setup();

Add the output price IDs to your env:

STRIPE_STARTER_PRICE_ID=price_xxx
STRIPE_PRO_PRICE_ID=price_yyy

Step 2: Create the Checkout Session

Create app/api/payments/stripe/create-checkout/route.ts:

import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { priceId } = await req.json();

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: { stripeCustomerId: true, email: true },
  });

  // Reuse existing Stripe customer if available
  let customerId = user?.stripeCustomerId;

  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user?.email ?? session.user.email ?? undefined,
      metadata: { userId: session.user.id },
    });
    customerId = customer.id;

    await prisma.user.update({
      where: { id: session.user.id },
      data: { stripeCustomerId: customerId },
    });
  }

  const checkoutSession = await stripe.checkout.sessions.create({
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: "subscription",
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    subscription_data: {
      metadata: { userId: session.user.id },
    },
    allow_promotion_codes: true,
  });

  return NextResponse.json({ url: checkoutSession.url });
}

Step 3: Build the Pricing Page

"use client";

import { useState } from "react";

const plans = [
  {
    name: "Starter",
    price: "$19",
    period: "month",
    priceId: process.env.NEXT_PUBLIC_STRIPE_STARTER_PRICE_ID!,
    features: ["Auth", "Payments", "Database", "Email", "UI components"],
  },
  {
    name: "Pro",
    price: "$29",
    period: "month",
    priceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID!,
    features: ["Everything in Starter", "AI integrations", "Error tracking", "Priority support"],
    popular: true,
  },
];

export function PricingSection() {
  const [loading, setLoading] = useState<string | null>(null);

  const handleSubscribe = async (priceId: string) => {
    setLoading(priceId);

    const res = await fetch("/api/payments/stripe/create-checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ priceId }),
    });

    const { url } = await res.json();
    window.location.href = url;
  };

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
      {plans.map((plan) => (
        <div
          key={plan.name}
          className={`rounded-xl border p-8 ${
            plan.popular ? "border-emerald-500 bg-emerald-950/20" : "border-white/10"
          }`}
        >
          {plan.popular && (
            <span className="text-xs font-semibold text-emerald-400 uppercase tracking-wider">
              Most Popular
            </span>
          )}
          <h3 className="text-2xl font-bold text-white mt-2">{plan.name}</h3>
          <div className="mt-4">
            <span className="text-4xl font-bold text-white">{plan.price}</span>
            <span className="text-muted-foreground">/{plan.period}</span>
          </div>
          <ul className="mt-6 space-y-3">
            {plan.features.map((feature) => (
              <li key={feature} className="flex items-center gap-2 text-sm text-[#EDEDED]">
                <span className="text-emerald-400">✓</span>
                {feature}
              </li>
            ))}
          </ul>
          <button
            onClick={() => handleSubscribe(plan.priceId)}
            disabled={loading === plan.priceId}
            className={`mt-8 w-full rounded-md px-6 py-3 font-semibold ${
              plan.popular
                ? "bg-emerald-500 text-black hover:bg-emerald-400"
                : "bg-white/10 text-white hover:bg-white/20"
            }`}
          >
            {loading === plan.priceId ? "Loading..." : `Get ${plan.name}`}
          </button>
        </div>
      ))}
    </div>
  );
}

Step 4: Implement Paywalls

A reusable paywall component that wraps any feature:

// components/Paywall.tsx
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import Link from "next/link";

interface PaywallProps {
  requiredPlan: "starter" | "pro";
  children: React.ReactNode;
}

export async function Paywall({ requiredPlan, children }: PaywallProps) {
  const session = await auth();

  if (!session?.user?.id) {
    return <Link href="/login">Sign in to access this feature</Link>;
  }

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: { plan: true, stripeCurrentPeriodEnd: true },
  });

  const planOrder = { free: 0, starter: 1, pro: 2 };
  const userPlanLevel = planOrder[user?.plan as keyof typeof planOrder] ?? 0;
  const requiredPlanLevel = planOrder[requiredPlan];

  const isActive =
    user?.stripeCurrentPeriodEnd && user.stripeCurrentPeriodEnd > new Date();

  const hasAccess = isActive && userPlanLevel >= requiredPlanLevel;

  if (!hasAccess) {
    return (
      <div className="rounded-lg border border-white/10 p-8 text-center">
        <h3 className="text-lg font-semibold text-white">
          {requiredPlan === "pro" ? "Pro" : "Starter"} feature
        </h3>
        <p className="mt-2 text-muted-foreground">
          Upgrade your plan to access this feature.
        </p>
        <Link
          href="/pricing"
          className="mt-4 inline-block rounded-md bg-emerald-500 px-6 py-2 text-black font-semibold"
        >
          Upgrade now
        </Link>
      </div>
    );
  }

  return <>{children}</>;
}

Use it anywhere in your app:

export default async function AIPage() {
  return (
    <Paywall requiredPlan="pro">
      <AIFeature />
    </Paywall>
  );
}

Step 5: Handle the Subscription Lifecycle via Webhooks

Your checkout flow covers the initial purchase. Webhooks handle everything that happens after — refer to the Stripe Webhooks guide for the full implementation.

The key events for subscription lifecycle:

switch (event.type) {
  case "checkout.session.completed":
    // Grant initial access
    break;
  case "customer.subscription.updated":
    // Handle upgrades, downgrades, trial endings
    break;
  case "customer.subscription.deleted":
    // Revoke access, downgrade to free
    break;
  case "invoice.payment_succeeded":
    // Extend access period
    break;
  case "invoice.payment_failed":
    // Send warning email, don't revoke immediately
    break;
}

Step 6: Create a Billing Portal

Let users manage their subscription without building a custom UI:

// app/api/payments/stripe/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: { stripeCustomerId: true },
  });

  if (!user?.stripeCustomerId) {
    return NextResponse.json({ error: "No subscription found" }, { status: 400 });
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  });

  return NextResponse.json({ url: portalSession.url });
}

A button in your dashboard:

"use client";

export function ManageBillingButton() {
  const handleClick = async () => {
    const res = await fetch("/api/payments/stripe/portal", { method: "POST" });
    const { url } = await res.json();
    window.location.href = url;
  };

  return (
    <button onClick={handleClick} className="rounded-md border px-4 py-2 text-sm">
      Manage billing
    </button>
  );
}

Stripe's billing portal handles cancellations, plan changes, payment method updates, and invoice history — no custom UI required.


Testing the Full Flow

Before shipping, test every scenario:

# Trigger test events with Stripe CLI
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed

Also manually test:


The Honest Assessment

A complete subscription billing system — checkout, lifecycle management, paywalls, billing portal, and entitlement syncing — takes about 9–12 hours done correctly. That's not counting debugging, which adds time.

If you'd rather not spend a weekend on billing infrastructure, ZeroDrag ships with the complete Stripe subscription system pre-built — checkout, webhooks, the billing portal, paywalls, and entitlement sync. Starter at $119, Pro at $169, one-time.


Written by Utkarsh Singh. Last updated March 2026.

Skip the setup. Ship your product.

ZeroDrag gives you auth, payments, database, email, and AI — pre-wired. Starter at $119, Pro at $169. One-time payment.

Get ZeroDrag →