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:
- Trial (optional) — user gets access before paying
- Active — user is paying, has full access
- Past due — payment failed, Stripe is retrying
- Cancelled — user cancelled, access should end at period end
- 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:
- Subscribe to a plan → verify access granted
- Cancel → verify access continues until period end
- Reactivate → verify access restored
- Upgrade → verify new plan reflected immediately
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.