Build Recurring Subscriptions
with Stripe Billing
One-time payments are great, but SaaS runs on recurring revenue. In this lesson you'll create Products and Prices in Stripe, build a subscribe flow, manage subscription lifecycle states, and give customers a self-service portal — all in 15 minutes.
Why Stripe Billing?
If you're building a SaaS, subscriptions are your bread and butter. Stripe Billing handles the hard parts: recurring invoices, proration on plan changes, failed payment retries, and tax calculation. You define the products and prices — Stripe does the rest.
By the end of this lesson, a customer will be able to pick a plan, subscribe, and manage their own billing through Stripe's hosted Customer Portal. Zero custom billing UI required.
Predictable MRR
Stripe auto-charges customers on their billing cycle. No manual invoicing.
Built-in dunning
Failed payments are retried automatically. Stripe even emails customers for you.
Customer Portal
Plan changes, card updates, and cancellations — hosted by Stripe, zero UI code.
Before you start
- ✓A working Stripe Checkout integration — see Lesson 01 if you haven't set this up
- ✓A working webhook handler — see Lesson 02 for signature verification setup
- ✓Stripe secret key in your
.env.local - ✓Next.js app (App Router) with the
stripenpm package installed
Create Products and Prices in Stripe
Before you can charge anyone, you need a Product (what you're selling) and a Price (how much and how often). You can create these in the Dashboard, but doing it in code keeps everything version-controlled and reproducible.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function createPrices() {
// Create the product
const product = await stripe.products.create({
name: "Pro Plan",
description: "Full access to all lessons and future updates",
});
// Monthly price
const monthly = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00
currency: "usd",
recurring: { interval: "month" },
});
// Annual price (2 months free)
const annual = await stripe.prices.create({
product: product.id,
unit_amount: 29000, // $290.00 ($24.17/mo)
currency: "usd",
recurring: { interval: "year" },
});
console.log("Product:", product.id);
console.log("Monthly price:", monthly.id);
console.log("Annual price:", annual.id);
}
createPrices();Run it once with npx tsx scripts/create-prices.ts and save the IDs to your environment:
STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_PRICE_MONTHLY=price_1abc...
STRIPE_PRICE_ANNUAL=price_2def...products.create()Creates a Product — think of it as a container. One product can have multiple prices (monthly, annual, enterprise).prices.create()Defines how much to charge and how often. The recurring.interval field is what makes it a subscription price.unit_amountAlways in the smallest currency unit. For USD, that's cents: 2900 = $29.00.You can also create Products and Prices in the Stripe Dashboard (Product catalog → Add product). Either way works — just make sure the Price IDs end up in your env vars.
Build the subscribe API route
This endpoint creates a Stripe Checkout Session in subscription mode. The only difference from Lesson 01's one-time checkout is the mode parameter and using a recurring Price.
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { priceId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
}mode: "subscription"Tells Stripe this is a recurring charge, not a one-time payment. Stripe creates a Subscription object automatically.price: priceIdThe recurring Price ID from step 1. Pass it from the frontend so users can choose monthly or annual.{CHECKOUT_SESSION_ID}Stripe replaces this placeholder with the actual session ID in the redirect URL. Useful for looking up the subscription.Add the subscribe button
A simple client component that calls your API route and redirects to Stripe Checkout. Wire this into your pricing page or landing page.
"use client";
import { useState } from "react";
export function SubscribeButton({
priceId,
label,
}: {
priceId: string;
label: string;
}) {
const [loading, setLoading] = useState(false);
const handleSubscribe = async () => {
setLoading(true);
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
const { url } = await res.json();
window.location.href = url;
};
return (
<button
onClick={handleSubscribe}
disabled={loading}
>
{loading ? "Redirecting..." : label}
</button>
);
}Use it in your pricing page like this:
<SubscribeButton
priceId={process.env.NEXT_PUBLIC_PRICE_MONTHLY!}
label="Subscribe — $29/mo"
/>
<SubscribeButton
priceId={process.env.NEXT_PUBLIC_PRICE_ANNUAL!}
label="Subscribe — $290/yr (save $58)"
/>Price IDs used on the client must be prefixed with NEXT_PUBLIC_ to be available in the browser. Your secret key stays server-side only — never expose it.
Handle subscription webhooks
Subscriptions generate more events than one-time payments. Here are the four you must handle to keep your app's subscription state in sync with Stripe:
customer.subscription.createdNew subscriberFires when a customer completes checkout and a subscription is created. Save the subscription ID and status to your database.
customer.subscription.updatedState changesFires on plan changes, payment failures, renewals, and cancellations. This is the single most important subscription event — it covers transitions between active, past_due, and canceled.
customer.subscription.deletedFinal cancellationFires when a subscription is fully terminated (after the billing period ends if they canceled mid-cycle). Revoke access here.
invoice.payment_failedPayment problemFires when a renewal charge fails. The subscription moves to past_due. Optionally email the customer to update their card.
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await db.subscriptions.upsert({
stripeCustomerId: subscription.customer as string,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
status: subscription.status, // "active" | "past_due" | "canceled" | ...
currentPeriodEnd: new Date(
subscription.current_period_end * 1000
),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
// Optional: send a "please update your card" email
console.log("Payment failed for customer:", customerId);
break;
}Always store subscription.status directly from the webhook. Don't try to compute it yourself — Stripe manages the state machine. Your job is just to mirror it.
Understand subscription states
Stripe subscriptions have a lifecycle. Here are the states you'll see and what to do with each:
active
Payment succeeded, customer has full access. This is the happy path. Check current_period_end to know when the next billing cycle starts.
past_due
The latest invoice payment failed. Stripe will retry automatically (configurable in your Billing settings). Most indie hackers keep access active during past_due but show a banner asking the customer to update their payment method.
canceled
The subscription is done. If the customer canceled mid-cycle, check cancel_at_period_end — if true, they still have access until current_period_end. Revoke access only after that date passes.
incomplete / incomplete_expired
The first payment failed or requires authentication (3D Secure). Never grant access for these. If the payment doesn't complete within 23 hours, Stripe moves it to incomplete_expired.
Here's a simple helper to check access in your app:
export function hasActiveSubscription(sub: {
status: string;
cancelAtPeriodEnd: boolean;
currentPeriodEnd: Date;
}): boolean {
// Active and not canceling
if (sub.status === "active" && !sub.cancelAtPeriodEnd) return true;
// Active but canceling — still has access until period ends
if (sub.status === "active" && sub.cancelAtPeriodEnd) {
return new Date() < sub.currentPeriodEnd;
}
// Past due — keep access but show a warning banner
if (sub.status === "past_due") return true;
return false;
}Don't revoke access the instant someone cancels. If cancel_at_period_end is true, they've paid through the end of the cycle. Cutting them off early is a fast way to get chargebacks and angry tweets.
Add the Stripe Customer Portal
Instead of building custom UI for plan management, card updates, and cancellation, use Stripe's hosted Customer Portal. One API call gives your customers a full self-service billing dashboard.
First, enable the Customer Portal in your Stripe Dashboard → Settings → Billing → Customer portal. Configure which actions customers can perform (cancel, switch plans, update payment method).
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { customerId } = await req.json();
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.json({ url: session.url });
}And a button component to redirect customers:
"use client";
export function ManageBillingButton({
customerId,
}: {
customerId: string;
}) {
const handleClick = async () => {
const res = await fetch("/api/portal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customerId }),
});
const { url } = await res.json();
window.location.href = url;
};
return (
<button onClick={handleClick}>
Manage billing
</button>
);
}billingPortal.sessionsCreates a one-time URL for the Customer Portal. It expires after use — generate a fresh one each time.customerThe Stripe Customer ID (cus_xxx). You saved this when the subscription was created.return_urlWhere the customer goes after leaving the portal. Usually your app's dashboard or account page.You must enable the Customer Portal in the Stripe Dashboard before calling this API. If you skip this step, the API call will return an error saying the portal isn't configured.
Test the full flow
With your dev server and Stripe CLI running, walk through the complete subscription lifecycle:
- 1Click “Subscribe” and complete checkout with test card
4242 4242 4242 4242 - 2Check your webhook handler — you should see
customer.subscription.createdwith statusactive - 3Open the Customer Portal and cancel the subscription
- 4Verify
customer.subscription.updatedfires withcancel_at_period_end: true - 5Test a failed payment with
4000 0000 0000 0341— this card always declines after the first charge
Stripe also lets you fast-forward time in test mode. In the Dashboard, go to a test subscription and click “Advance time” to simulate renewals without waiting a month.
The test card 4000 0000 0000 0341 succeeds on the first charge but fails on subsequent charges. Perfect for testing the past_due flow.
Common gotchas
“No such price: price_xxx”
You're mixing test and live mode. Prices created in test mode start with price_ and only work with test-mode API keys. Make sure your env vars match the same mode.
Subscription created but user has no access
Your webhook handler probably isn't saving the subscription to your database. Check that customer.subscription.created is in your webhook switch statement and that the DB write succeeds.
Customer Portal says “not configured”
You need to enable and configure the Customer Portal in your Stripe Dashboard before the API works. Go to Settings → Billing → Customer portal and toggle it on.
Customer can't resubscribe after canceling
Once a subscription is canceled, you can't reactivate it. The customer needs to go through checkout again to create a new subscription. You can allow resubscription in the Customer Portal settings.
Ship-it checklist
Before you ship subscriptions to production:
What you just built
In about 15 minutes you added a complete subscription billing system:
- ✓Products and Prices for monthly and annual plans
- ✓A subscribe API route that creates subscription Checkout Sessions
- ✓Webhook handlers for the full subscription lifecycle
- ✓Subscription state management (active, past_due, canceled)
- ✓Stripe Customer Portal for self-service billing management
Your SaaS now has real recurring revenue infrastructure. Stripe handles invoicing, retries, and proration — you just mirror the subscription state and check access. That's it. Ship it.
Next up: Stripe Connect for Platforms & Marketplaces
Split payments between sellers and your platform. Onboard connected accounts and handle payouts — in 15 minutes.
Unlock all lessons — $14.50/mo→Cancel anytime · Instant access to all 12 lessons