Pro LessonLesson 03 of 12

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.

~15 min readNext.js + Node.jsStripe Billing API

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 stripe npm package installed
1

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.

scripts/create-prices.tstypescript
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:

.env.localenv
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.

2

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.

app/api/subscribe/route.tstypescript
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.
3

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.

app/components/SubscribeButton.tsxtypescript
"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:

app/pricing/page.tsx (excerpt)tsx
<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.

4

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 subscriber

Fires when a customer completes checkout and a subscription is created. Save the subscription ID and status to your database.

customer.subscription.updatedState changes

Fires 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 cancellation

Fires when a subscription is fully terminated (after the billing period ends if they canceled mid-cycle). Revoke access here.

invoice.payment_failedPayment problem

Fires when a renewal charge fails. The subscription moves to past_due. Optionally email the customer to update their card.

app/api/webhooks/stripe/route.ts (add to your switch)typescript
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.

5

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:

lib/subscriptions.tstypescript
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.

6

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).

app/api/portal/route.tstypescript
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:

app/components/ManageBillingButton.tsxtypescript
"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.

7

Test the full flow

With your dev server and Stripe CLI running, walk through the complete subscription lifecycle:

  1. 1Click “Subscribe” and complete checkout with test card 4242 4242 4242 4242
  2. 2Check your webhook handler — you should see customer.subscription.created with status active
  3. 3Open the Customer Portal and cancel the subscription
  4. 4Verify customer.subscription.updated fires with cancel_at_period_end: true
  5. 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